Compare commits
80 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c6cb735a09 | |||
| 461ab70918 | |||
| bc6bf73e71 | |||
| 566b085ede | |||
| 84d51bc86c | |||
| 66eab072ee | |||
| f390ad46d5 | |||
| 6bdf1d173b | |||
| 02cc74e06f | |||
| 784d97efd6 | |||
| 1f44edcdf3 | |||
| 92e87fcd32 | |||
| 5138f66262 | |||
| 9a21404d23 | |||
| 85784419a3 | |||
| 3431df05af | |||
| 4e9dd87a7a | |||
| e4c27aa840 | |||
| 280cd01df5 | |||
| 1a552282a5 | |||
| 37fe23c0f7 | |||
| 255da4bf60 | |||
| 6dccb507d2 | |||
| d202a025e7 | |||
| ac9ba0df62 | |||
| 91562d049c | |||
| 60321296e5 | |||
| ae06873ff5 | |||
| 70ffb758c7 | |||
| 33a94da636 | |||
| aa6757601d | |||
| c26b1b711c | |||
| 3ee33fc2ec | |||
| d43b2857c1 | |||
| 3b1b644ef9 | |||
| ab7a0e1ec2 | |||
| 86a4d68daa | |||
| de3df889ba | |||
| 7c9fc15359 | |||
| a6a67f2752 | |||
| 00eb52cd3f | |||
| 6324be9fd3 | |||
| 66f307b7e8 | |||
| 506b3978e1 | |||
| db65aef56e | |||
| 4e1a4be650 | |||
| 1f603ce17b | |||
| 672ca7dfe4 | |||
| 7405011cd2 | |||
| 1d5aebcfdc | |||
| 0d6881382b | |||
| c428f39456 | |||
| cd9b80b966 | |||
| f80603b4bb | |||
| 3299ae7c39 | |||
| 93ca4f2bf4 | |||
| 19a4e442c5 | |||
| 53f1c9c1d4 | |||
| 28538b63da | |||
| 94ae5d52c2 | |||
| 6b38a3c996 | |||
| 0af30ec366 | |||
| 33e017464f | |||
| 9e91bbe417 | |||
| e821982353 | |||
| 18f4a117ab | |||
| b5b99d4c52 | |||
| 8a4323aa82 | |||
| 5cbac23f13 | |||
| d55810784d | |||
| c2fdefe7b2 | |||
| bf2474d0e1 | |||
| 558d844ef3 | |||
| 94aa828d87 | |||
| 110e062e56 | |||
| f7c2beb30d | |||
| 5bf1456200 | |||
| 80e5c9bb95 | |||
| 74164b8498 | |||
| 82cc2e5d17 |
@@ -1,3 +0,0 @@
|
||||
[submodule "secrets"]
|
||||
path = secrets
|
||||
url = ../secrets
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2024-2025 DINUM/Etalab
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -1,6 +1,6 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2021-2023 DINUM/Etalab
|
||||
Copyright (c) 2024-2025 DINUM/Etalab
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<p align="center">
|
||||
<img alt="posthoglogo" src="./docs/assets/visio-logo.png" maxWidth="100%">
|
||||
<img alt="meet logo" src="./docs/assets/banner-meet-fr.png" maxWidth="100%">
|
||||
</p>
|
||||
|
||||
|
||||
@@ -21,13 +21,13 @@
|
||||
|
||||
<p align="center">
|
||||
<a href="https://visio.numerique.gouv.fr/">
|
||||
<img src="https://github.com/user-attachments/assets/09c1faa1-de88-4848-af3a-6fbe793999bf" alt="Visio Demonstration">
|
||||
<img src="https://github.com/user-attachments/assets/09c1faa1-de88-4848-af3a-6fbe793999bf" alt="La Suite Meet Demonstration">
|
||||
</a>
|
||||
</p>
|
||||
|
||||
## Visio: Simple Video Conferencing
|
||||
## La Suite Meet: Simple Video Conferencing
|
||||
|
||||
Powered by [LiveKit](https://livekit.io/), Visio offers Zoom-level performance with high-quality video and audio. No installation required—simply join calls directly from your browser. Check out LiveKit's impressive optimizations in their [blog post](https://blog.livekit.io/livekit-one-dot-zero/).
|
||||
Powered by [LiveKit](https://livekit.io/), La Suite Meet offers Zoom-level performance with high-quality video and audio. No installation required—simply join calls directly from your browser. Check out LiveKit's impressive optimizations in their [blog post](https://blog.livekit.io/livekit-one-dot-zero/).
|
||||
### Features
|
||||
- Optimized for stability in large meetings (+100 p.)
|
||||
- Support for multiple screen sharing streams
|
||||
@@ -45,7 +45,7 @@ Powered by [LiveKit](https://livekit.io/), Visio offers Zoom-level performance w
|
||||
- SVC codecs (VP9, AV1)
|
||||
|
||||
|
||||
Visio is fully self-hostable and released under the MIT License, ensuring complete control and flexibility. It's simple to [get started](https://visio.numerique.gouv.fr/) or [request a demo](mailto:visio@numerique.gouv.fr).
|
||||
La Suite Meet is fully self-hostable and released under the MIT License, ensuring complete control and flexibility. It's simple to [get started](https://visio.numerique.gouv.fr/) or [request a demo](mailto:visio@numerique.gouv.fr).
|
||||
|
||||
We’re continuously adding new features to enhance your experience, with the latest updates coming soon!
|
||||
|
||||
@@ -61,31 +61,31 @@ We’re continuously adding new features to enhance your experience, with the la
|
||||
|
||||
## Get started
|
||||
|
||||
### Visio Cloud (Recommended)
|
||||
Sign up for Visio Cloud, designed for European public servants. Hosted on SecNumCloud-compliant providers and accessible via government SSO, [ProConnect](https://www.proconnect.gouv.fr/). The easiest way to try our product. Reach out if your entity isn't connected yet to our sso.
|
||||
### La Suite Meet Cloud (Recommended)
|
||||
Sign up for La Suite Meet Cloud, designed for french public servants. Hosted on SecNumCloud-compliant providers and accessible via government SSO, [ProConnect](https://www.proconnect.gouv.fr/). The easiest way to try our product. Reach out if your entity isn't connected yet to our sso.
|
||||
|
||||
### Open-source deployment (Advanced)
|
||||
|
||||
Deploy Visio on your own infrastructure using [our self-hosting guide](https://github.com/suitenumerique/meet/blob/main/docs/installation.md). Our open-source deployment is optimized for Kubernetes, and we're working on supporting additional deployment options. Keycloak integration and any SSO are supported. We offer customer support for open-source setups—just reach out for assistance.
|
||||
Deploy La Suite Meet on your own infrastructure using [our self-hosting guide](https://github.com/suitenumerique/meet/blob/main/docs/installation.md). Our open-source deployment is optimized for Kubernetes, and we're working on supporting additional deployment options. Keycloak integration and any SSO are supported. We offer customer support for open-source setups—just reach out for assistance.
|
||||
|
||||
## Docs
|
||||
|
||||
We're currently working on both technical and user documentation for Visio. In the meantime, many of the essential aspects are already well covered by the [LiveKit documentation](https://docs.livekit.io/home/) and their [self-hosting guide](https://docs.livekit.io/home/self-hosting/deployment/). Stay tuned for more updates!
|
||||
We're currently working on both technical and user documentation for La Suite Meet. In the meantime, many of the essential aspects are already well covered by the [LiveKit documentation](https://docs.livekit.io/home/) and their [self-hosting guide](https://docs.livekit.io/home/self-hosting/deployment/). Stay tuned for more updates!
|
||||
|
||||
## Contributing
|
||||
|
||||
We <3 contributions of any kind, big and small:
|
||||
|
||||
- Vote on features or get early access to beta functionality in our [roadmap](https://github.com/orgs/suitenumerique/projects/3/views/2)
|
||||
- Open a PR (see our instructions on [developing Visio locally](https://github.com/suitenumerique/meet/blob/main/docs/developping_locally.md))
|
||||
- Open a PR (see our instructions on [developing La Suite Meet locally](https://github.com/suitenumerique/meet/blob/main/docs/developping_locally.md))
|
||||
- Submit a [feature request](https://github.com/suitenumerique/meet/issues/new?assignees=&labels=enhancement&template=Feature_request.md) or [bug report](https://github.com/suitenumerique/meet/issues/new?assignees=&labels=bug&template=Bug_report.md)
|
||||
|
||||
|
||||
## Philosophy
|
||||
|
||||
We’re relentlessly focused on building the best open-source video conferencing product—Visio. Growth comes from creating something people truly need, not just from chasing metrics.
|
||||
We’re relentlessly focused on building the best open-source video conferencing product—La Suite Meet. Growth comes from creating something people truly need, not just from chasing metrics.
|
||||
|
||||
Our users come first. We’re committed to making Visio as accessible and easy to use as proprietary solutions, ensuring it meets the highest standards.
|
||||
Our users come first. We’re committed to making La Suite Meet as accessible and easy to use as proprietary solutions, ensuring it meets the highest standards.
|
||||
|
||||
Most of the heavy engineering is handled by the incredible LiveKit team, allowing us to focus on delivering a top-tier product. We follow extreme programming practices, favoring pair programming and quick, iterative releases. Challenge our tech and architecture—simplicity is always our top priority.
|
||||
|
||||
@@ -99,7 +99,7 @@ To learn more, don't hesitate to [reach out](mailto:visio@numerique.gouv.fr).
|
||||
|
||||
### Help us!
|
||||
|
||||
Come help us make Visio even better. We're growing fast and [would love some help](mailto:visio@numerique.gouv.fr).
|
||||
Come help us make La Suite Meet even better. We're growing fast and [would love some help](mailto:visio@numerique.gouv.fr).
|
||||
|
||||
|
||||
## Contributors 🧞
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM livekit/livekit-server:latest
|
||||
FROM livekit/livekit-server:v1.8.0
|
||||
|
||||
# We inject the nip.io certificate manually because the livekit chart doesn't support volume mounting
|
||||
COPY rootCA.pem /etc/ssl/certs/
|
||||
|
||||
|
After Width: | Height: | Size: 216 KiB |
|
Before Width: | Height: | Size: 28 KiB |
@@ -2,7 +2,6 @@
|
||||
|
||||
This document is a step-by-step guide that describes how to install Visio on a k8s cluster without AI features.
|
||||
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- k8s cluster with an nginx-ingress controller
|
||||
@@ -43,11 +42,11 @@ It will expire on 23 March 2027 🗓
|
||||
2. Create kind cluster with containerd registry config dir enabled
|
||||
Creating cluster "visio" ...
|
||||
✓ Ensuring node image (kindest/node:v1.27.3) 🖼
|
||||
✓ Preparing nodes 📦
|
||||
✓ Writing configuration 📜
|
||||
✓ Starting control-plane 🕹️
|
||||
✓ Installing CNI 🔌
|
||||
✓ Installing StorageClass 💾
|
||||
✓ Preparing nodes 📦
|
||||
✓ Writing configuration 📜
|
||||
✓ Starting control-plane 🕹️
|
||||
✓ Installing CNI 🔌
|
||||
✓ Installing StorageClass 💾
|
||||
Set kubectl context to "kind-visio"
|
||||
You can now use your cluster with:
|
||||
|
||||
@@ -95,13 +94,14 @@ ingress-nginx-admission-create-jgnc9 0/1 Completed 0 2m44s
|
||||
ingress-nginx-admission-patch-wrt47 0/1 Completed 0 2m44s
|
||||
ingress-nginx-controller-57c548c4cd-9xwt6 1/1 Running 0 2m44s
|
||||
```
|
||||
When your k8s cluster is ready, you can start the deployment. This cluster is special because it uses the *.127.0.0.1.nip.io domain and mkcert certificates to have full HTTPS support and easy domain name management.
|
||||
|
||||
Please remember that *.127.0.0.1.nip.io will always resolve to 127.0.0.1, except in the k8s cluster where we configure CoreDNS to answer with the ingress-nginx service IP.
|
||||
When your k8s cluster is ready, you can start the deployment. This cluster is special because it uses the \*.127.0.0.1.nip.io domain and mkcert certificates to have full HTTPS support and easy domain name management.
|
||||
|
||||
Please remember that \*.127.0.0.1.nip.io will always resolve to 127.0.0.1, except in the k8s cluster where we configure CoreDNS to answer with the ingress-nginx service IP.
|
||||
|
||||
## Preparation
|
||||
|
||||
### What will you use to authenticate your users ?
|
||||
### What will you use to authenticate your users ?
|
||||
|
||||
Visio uses OIDC, so if you already have an OIDC provider, obtain the necessary information to use it. In the next step, we will see how to configure Django (and thus Visio) to use it. If you do not have a provider, we will show you how to deploy a local Keycloak instance (this is not a production deployment, just a demo).
|
||||
|
||||
@@ -153,6 +153,7 @@ keycloak-0 1/1 Running 0 26m
|
||||
keycloak-postgresql-0 1/1 Running 0 26m
|
||||
redis-master-0 1/1 Running 0 35s
|
||||
```
|
||||
|
||||
When the redis is ready we can deploy livekit-server.
|
||||
|
||||
```
|
||||
@@ -194,6 +195,7 @@ livekit-livekit-server-5c5fb87f7f-ct6x5 1/1 Running 0 15m
|
||||
postgresql-0 1/1 Running 0 50s
|
||||
redis-master-0 1/1 Running 0 19
|
||||
```
|
||||
|
||||
From here important information you will need are :
|
||||
|
||||
```
|
||||
@@ -212,7 +214,7 @@ POSTGRES_PASSWORD: pass
|
||||
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.
|
||||
|
||||
```
|
||||
$ helm repo add meet https://numerique-gouv.github.io/meet/
|
||||
$ helm repo add meet https://suitenumerique.github.io/meet/
|
||||
$ helm repo update
|
||||
$ helm install meet meet/meet -f examples/meet.values.yaml
|
||||
```
|
||||
@@ -231,3 +233,98 @@ meet-admin <none> meet.127.0.0.1.nip.io localhost 80, 44
|
||||
```
|
||||
|
||||
You can use Visio on https://meet.127.0.0.1.nip.io. The provisioning user in keycloak is meet/meet.
|
||||
|
||||
## All options
|
||||
|
||||
These are the environmental options available on meet backend.
|
||||
|
||||
| Option | Description | default |
|
||||
| ----------------------------------------------- | ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| DJANGO_ALLOWED_HOSTS | Host that are allowed | [] |
|
||||
| DJANGO_SECRET_KEY | Secret key used | |
|
||||
| DJANGO_SILENCED_SYSTEM_CHECKS | Silence system checks | [] |
|
||||
| DJANGO_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 | |
|
||||
| DJANGO_LANGUAGE_CODE | Default language | en-us |
|
||||
| REDIS_URL | redis endpoint | redis://redis:6379/1 |
|
||||
| REQUEST_ENTRY_THROTTLE_RATES | throttle rates | 150/minute |
|
||||
| SPECTACULAR_SETTINGS_ENABLE_DJANGO_DEPLOY_CHECK | deploy check | False |
|
||||
| FRONTEND_ANALYTICS | analytics information | {} |
|
||||
| FRONTEND_SUPPORT | crisp frontend support | {} |
|
||||
| FRONTEND_SILENCE_LIVEKIT_DEBUG | silence livekit debug | 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 tto the email server | |
|
||||
| DJANGO_EMAIL_PORT | por tot connect to the email server | |
|
||||
| DJANGO_EMAIL_USE_TLS | enable tls on email connection | false |
|
||||
| DJANGO_EMAIL_USE_SSL | enable tls on email connection | false |
|
||||
| DJANGO_EMAIL_FROM | email from account | from@example.com |
|
||||
| DJANGO_CORS_ALLOW_ALL_ORIGINS | allow all cors origins | true |
|
||||
| DJANGO_CORS_ALLOWED_ORIGINS | origins to allow in string | [] |
|
||||
| DJANGO_CORS_ALLOWED_ORIGIN_REGEXES | origins to allow in regex | [] |
|
||||
| SENTRY_DSN | sentry server | |
|
||||
| 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 | false |
|
||||
| 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 algoritm used by oidc | RS256 |
|
||||
| OIDC_RP_CLIENT_ID | oidc client | 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_LOGOUT_ENDPOINT | oidc endpoint for logout | |
|
||||
| OIDC_AUTH_REQUEST_EXTRA_PARAMS | extra parameters for oidc request | |
|
||||
| OIDC_RP_SCOPES | oidc scopes | openid email |
|
||||
| LOGIN_REDIRECT_URL | login redirect url | |
|
||||
| LOGIN_REDIRECT_URL_FAILURE | login redurect url for failure | |
|
||||
| LOGOUT_REDIRECT_URL | url to redirect to on logout | |
|
||||
| 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 |
|
||||
| ALLOW_LOGOUT_GET_METHOD | allow logout through get method | true |
|
||||
| OIDC_REDIRECT_FIELD_NAME | direct field for oidc | returnTo |
|
||||
| OIDC_USERINFO_FULLNAME_FIELDS | full name claim from OIDC token | ["given_name", "usual_name"] |
|
||||
| OIDC_USERINFO_SHORTNAME_FIELD | shortname claim from OIDC token | given_name |
|
||||
| LIVEKIT_API_KEY | livekit api key | |
|
||||
| LIVEKIT_API_SECRET | livekit api secret | |
|
||||
| LIVEKIT_API_URL | livekit api url | |
|
||||
| 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_VERIFY_SSL | verify ssl for recording storage | true |
|
||||
| RECORDING_WORKER_CLASSES | worker classes for recording | {"screen_recording": "core.recording.worker.services.VideoCompositeEgressService","transcript": "core.recording.worker.services.AudioCompositeEgressService"} |
|
||||
| RECORDING_EVENT_PARSER_CLASS | storage event engine for recording | core.recording.event.parsers.MinioParser |
|
||||
| RECORDING_ENABLE_STORAGE_EVENT_AUTH | enable storage event authorization | true |
|
||||
| RECORDING_STORAGE_EVENT_ENABLE | enable recording storage events | false |
|
||||
| RECORDING_STORAGE_EVENT_TOKEN | recording storage event token | |
|
||||
| SUMMARY_SERVICE_ENDPOINT | summary service endpoint | |
|
||||
| SUMMARY_SERVICE_API_TOKEN | api token voor summary service | |
|
||||
| SIGNUP_NEW_USER_TO_MARKETING_EMAIL | signup users to marketing emails | false |
|
||||
| MARKETING_SERVICE_CLASS | markering class | core.services.marketing.BrevoMarketingService |
|
||||
| BREVO_API_KEY | breva 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 | 1 |
|
||||
| LOBBY_KEY_PREFIX | lobby prefix | room_lobby |
|
||||
| LOBBY_WAITING_TIMEOUT | lobby waiting tumeout | 3 |
|
||||
| LOBBY_DENIED_TIMEOUT | lobby deny timeout | 5 |
|
||||
| LOBBY_ACCEPTED_TIMEOUT | lobby accept timeout | 21600 |
|
||||
| LOBBY_NOTIFICATION_TYPE | lobby notification types | participantWaiting |
|
||||
| LOBBY_COOKIE_NAME | lobby cookie name | lobbyParticipantId |
|
||||
|
||||
@@ -156,7 +156,7 @@ class RecordingSerializer(serializers.ModelSerializer):
|
||||
|
||||
class Meta:
|
||||
model = models.Recording
|
||||
fields = ["id", "room", "created_at", "updated_at", "status"]
|
||||
fields = ["id", "room", "created_at", "updated_at", "status", "mode"]
|
||||
read_only_fields = fields
|
||||
|
||||
|
||||
@@ -209,3 +209,17 @@ class ParticipantEntrySerializer(serializers.Serializer):
|
||||
def update(self, instance, validated_data):
|
||||
"""Not implemented as this is a validation-only serializer."""
|
||||
raise NotImplementedError("StartRecordingSerializer is validation-only")
|
||||
|
||||
|
||||
class CreationCallbackSerializer(serializers.Serializer):
|
||||
"""Validate room creation callback data."""
|
||||
|
||||
callback_id = serializers.CharField(required=True)
|
||||
|
||||
def create(self, validated_data):
|
||||
"""Not implemented as this is a validation-only serializer."""
|
||||
raise NotImplementedError("StartRecordingSerializer is validation-only")
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
"""Not implemented as this is a validation-only serializer."""
|
||||
raise NotImplementedError("StartRecordingSerializer is validation-only")
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
import uuid
|
||||
from logging import getLogger
|
||||
from urllib.parse import urlparse
|
||||
|
||||
|
||||
from django.conf import settings
|
||||
from django.db.models import Q
|
||||
@@ -20,7 +22,7 @@ from rest_framework import (
|
||||
status as drf_status,
|
||||
)
|
||||
|
||||
from core import models, utils
|
||||
from core import models, utils, enums
|
||||
from core.recording.event.authentication import StorageEventAuthentication
|
||||
from core.recording.event.exceptions import (
|
||||
InvalidBucketError,
|
||||
@@ -47,6 +49,7 @@ from core.services.lobby import (
|
||||
LobbyParticipantNotFound,
|
||||
LobbyService,
|
||||
)
|
||||
from core.services.room_creation import RoomCreation
|
||||
|
||||
from . import permissions, serializers
|
||||
|
||||
@@ -186,6 +189,12 @@ class RequestEntryAnonRateThrottle(throttling.AnonRateThrottle):
|
||||
scope = "request_entry"
|
||||
|
||||
|
||||
class CreationCallbackAnonRateThrottle(throttling.AnonRateThrottle):
|
||||
"""Throttle Anonymous user requesting room generation callback"""
|
||||
|
||||
scope = "creation_callback"
|
||||
|
||||
|
||||
class RoomViewSet(
|
||||
mixins.CreateModelMixin,
|
||||
mixins.DestroyModelMixin,
|
||||
@@ -268,6 +277,9 @@ class RoomViewSet(
|
||||
role=models.RoleChoices.OWNER,
|
||||
)
|
||||
|
||||
if callback_id := self.request.data.get("callback_id"):
|
||||
RoomCreation().persist_callback_state(callback_id, room)
|
||||
|
||||
@decorators.action(
|
||||
detail=True,
|
||||
methods=["post"],
|
||||
@@ -460,6 +472,31 @@ class RoomViewSet(
|
||||
{"status": "error", "message": str(e)}, status=status_code
|
||||
)
|
||||
|
||||
@decorators.action(
|
||||
detail=False,
|
||||
methods=["POST"],
|
||||
url_path="creation-callback",
|
||||
permission_classes=[],
|
||||
throttle_classes=[CreationCallbackAnonRateThrottle],
|
||||
)
|
||||
def creation_callback(self, request):
|
||||
"""Retrieve cached room data via an unauthenticated request with a unique ID.
|
||||
|
||||
Designed for interoperability across iframes, popups, and other contexts,
|
||||
even on the same domain, bypassing browser security restrictions on direct communication.
|
||||
"""
|
||||
|
||||
serializer = serializers.CreationCallbackSerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
room = RoomCreation().get_callback_state(
|
||||
callback_id=serializer.validated_data.get("callback_id")
|
||||
)
|
||||
|
||||
return drf_response.Response(
|
||||
{"status": "success", "room": room}, status=drf_status.HTTP_200_OK
|
||||
)
|
||||
|
||||
|
||||
class ResourceAccessListModelMixin:
|
||||
"""List mixin for resource access API."""
|
||||
@@ -509,6 +546,7 @@ class ResourceAccessViewSet(
|
||||
class RecordingViewSet(
|
||||
mixins.DestroyModelMixin,
|
||||
mixins.ListModelMixin,
|
||||
mixins.RetrieveModelMixin,
|
||||
viewsets.GenericViewSet,
|
||||
):
|
||||
"""
|
||||
@@ -556,7 +594,7 @@ class RecordingViewSet(
|
||||
)
|
||||
|
||||
try:
|
||||
recording = models.Recording.objects.get(id=recording_id)
|
||||
recording = models.Recording.objects.select_related('room').get(id=recording_id)
|
||||
except models.Recording.DoesNotExist as e:
|
||||
raise drf_exceptions.NotFound("No recording found for this event.") from e
|
||||
|
||||
@@ -582,3 +620,83 @@ class RecordingViewSet(
|
||||
return drf_response.Response(
|
||||
{"message": "Event processed."},
|
||||
)
|
||||
|
||||
|
||||
def _auth_get_original_url(self, request):
|
||||
"""
|
||||
Extracts and parses the original URL from the "HTTP_X_ORIGINAL_URL" header.
|
||||
Raises PermissionDenied if the header is missing.
|
||||
|
||||
The original url is passed by nginx in the "HTTP_X_ORIGINAL_URL" header.
|
||||
See corresponding ingress configuration in Helm chart and read about the
|
||||
nginx.ingress.kubernetes.io/auth-url annotation to understand how the Nginx ingress
|
||||
is configured to do this.
|
||||
|
||||
Based on the original url and the logged-in user, we must decide if we authorize Nginx
|
||||
to let this request go through (by returning a 200 code) or if we block it (by returning
|
||||
a 403 error). Note that we return 403 errors without any further details for security
|
||||
reasons.
|
||||
"""
|
||||
# Extract the original URL from the request header
|
||||
original_url = request.META.get("HTTP_X_ORIGINAL_URL")
|
||||
if not original_url:
|
||||
logger.debug("Missing HTTP_X_ORIGINAL_URL header in subrequest")
|
||||
raise drf_exceptions.PermissionDenied()
|
||||
|
||||
logger.debug("Original url: '%s'", original_url)
|
||||
return urlparse(original_url)
|
||||
|
||||
def _auth_get_url_params(self, pattern, fragment):
|
||||
"""
|
||||
Extracts URL parameters from the given fragment using the specified regex pattern.
|
||||
Raises PermissionDenied if parameters cannot be extracted.
|
||||
"""
|
||||
|
||||
match = pattern.search(fragment)
|
||||
|
||||
try:
|
||||
return match.groupdict()
|
||||
except (ValueError, AttributeError) as exc:
|
||||
logger.debug("Failed to extract parameters from subrequest URL: %s", exc)
|
||||
raise drf_exceptions.PermissionDenied() from exc
|
||||
|
||||
@decorators.action(detail=False, methods=["get"], url_path="media-auth")
|
||||
def media_auth(self, request, *args, **kwargs):
|
||||
"""
|
||||
This view is used by an Nginx subrequest to control access to a recording's
|
||||
media file.
|
||||
|
||||
When we let the request go through, we compute authorization headers that will be added to
|
||||
the request going through thanks to the nginx.ingress.kubernetes.io/auth-response-headers
|
||||
annotation. The request will then be proxied to the object storage backend who will
|
||||
respond with the file after checking the signature included in headers.
|
||||
"""
|
||||
|
||||
parsed_url = self._auth_get_original_url(request)
|
||||
|
||||
url_params = self._auth_get_url_params(
|
||||
enums.RECORDING_STORAGE_URL_PATTERN, parsed_url.path
|
||||
)
|
||||
|
||||
user = request.user
|
||||
recording_id = url_params['recording']
|
||||
|
||||
try:
|
||||
recording = models.Recording.objects.get(id=recording_id)
|
||||
except models.Recording.DoesNotExist as e:
|
||||
raise drf_exceptions.NotFound("No recording found for this event.") from e
|
||||
|
||||
abilities = recording.get_abilities(user)
|
||||
|
||||
if not abilities["retrieve"]:
|
||||
logger.debug("User '%s' lacks permission for attachment", user)
|
||||
raise drf_exceptions.PermissionDenied()
|
||||
|
||||
if not recording.is_saved:
|
||||
logger.debug("Recording '%s' has not been saved", recording)
|
||||
raise drf_exceptions.PermissionDenied()
|
||||
|
||||
# Generate S3 authorization headers using the extracted URL parameters
|
||||
request = utils.generate_s3_authorization_headers(recording.key)
|
||||
|
||||
return drf_response.Response("authorized", headers=request.headers, status=200)
|
||||
|
||||
@@ -121,7 +121,9 @@ class OIDCAuthenticationBackend(MozillaOIDCAuthenticationBackend):
|
||||
contact_data = ContactData(
|
||||
email=email, attributes={"VISIO_SOURCE": ["SIGNIN"]}
|
||||
)
|
||||
marketing_service.create_contact(contact_data, timeout=1)
|
||||
marketing_service.create_contact(
|
||||
contact_data, timeout=settings.BREVO_API_TIMEOUT
|
||||
)
|
||||
except (ContactCreationError, ImproperlyConfigured, ImportError):
|
||||
pass
|
||||
|
||||
|
||||
@@ -1,10 +1,22 @@
|
||||
"""
|
||||
Core application enums declaration
|
||||
"""
|
||||
import re
|
||||
|
||||
from django.conf import global_settings, settings
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
|
||||
UUID_REGEX = (
|
||||
r"[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}"
|
||||
)
|
||||
FILE_EXT_REGEX = r"\.[a-zA-Z0-9]{1,10}"
|
||||
RECORDING_FOLDER = 'recordings'
|
||||
RECORDING_STORAGE_URL_PATTERN = re.compile(
|
||||
f"/media/recordings/(?P<recording>{UUID_REGEX:s})(?P<extension>{FILE_EXT_REGEX:s})"
|
||||
)
|
||||
|
||||
|
||||
# Django sets `LANGUAGES` by default with all supported languages. We can use it for
|
||||
# the choice of languages which should not be limited to the few languages active in
|
||||
# the app.
|
||||
|
||||
@@ -576,6 +576,18 @@ class Recording(BaseModel):
|
||||
RecordingStatusChoices.STOPPED,
|
||||
}
|
||||
|
||||
@property
|
||||
def is_saved(self) -> bool:
|
||||
"""Wip"""
|
||||
return self.status in {
|
||||
RecordingStatusChoices.SAVED,
|
||||
}
|
||||
|
||||
@property
|
||||
def key(self):
|
||||
"""Wip."""
|
||||
extension = "ogg" if self.mode == RecordingModeChoices.TRANSCRIPT else "mp4"
|
||||
return f"{settings.RECORDING_OUTPUT_FOLDER}/{self.id}.{extension}"
|
||||
|
||||
class RecordingAccess(BaseAccess):
|
||||
"""Relation model to give access to a recording for a user or a team with a role."""
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
"""Service to notify external services when a new recording is ready."""
|
||||
|
||||
import logging
|
||||
import smtplib
|
||||
|
||||
from django.conf import settings
|
||||
from django.template.loader import render_to_string
|
||||
from django.utils.translation import get_language, override
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.contrib.sites.models import Site
|
||||
from django.core.mail import send_mail
|
||||
|
||||
import requests
|
||||
|
||||
@@ -21,10 +27,7 @@ class NotificationService:
|
||||
return self._notify_summary_service(recording)
|
||||
|
||||
if recording.mode == models.RecordingModeChoices.SCREEN_RECORDING:
|
||||
logger.warning(
|
||||
"Screen recording mode not implemented for recording %s", recording.id
|
||||
)
|
||||
return False
|
||||
return self._notify_user_by_email(recording)
|
||||
|
||||
logger.error(
|
||||
"Unknown recording mode %s for recording %s",
|
||||
@@ -33,6 +36,65 @@ class NotificationService:
|
||||
)
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def _notify_user_by_email(recording) -> bool:
|
||||
"""Wip."""
|
||||
|
||||
# subject
|
||||
# emails
|
||||
domain = Site.objects.get_current().domain
|
||||
|
||||
owner_accesses = (
|
||||
models.RecordingAccess.objects.select_related("user")
|
||||
.filter(
|
||||
role=models.RoleChoices.OWNER,
|
||||
recording_id=recording.id,
|
||||
)
|
||||
.all()
|
||||
)
|
||||
|
||||
if not owner_accesses:
|
||||
logger.error("No owner found for recording %s", recording.id)
|
||||
return False
|
||||
|
||||
language = get_language()
|
||||
|
||||
context = (
|
||||
{
|
||||
"brandname": settings.EMAIL_BRAND_NAME,
|
||||
"support_email": settings.EMAIL_SUPPORT_EMAIL,
|
||||
"logo_img": settings.EMAIL_LOGO_IMG,
|
||||
"domain": domain,
|
||||
"room_name": recording.room.name,
|
||||
"recording_date": recording.created_at.strftime("%A %d %B %Y"),
|
||||
"recording_time": recording.created_at.strftime("%H:%M"),
|
||||
"link": f"{domain}/recordings/{recording.id}",
|
||||
}
|
||||
)
|
||||
|
||||
emails = [access.user.email for access in owner_accesses]
|
||||
|
||||
with override(language):
|
||||
msg_html = render_to_string("mail/html/screen_recording.html", context)
|
||||
msg_plain = render_to_string("mail/text/screen_recording.txt", context)
|
||||
subject = str(_("Your recording is ready")) # Force translation
|
||||
|
||||
try:
|
||||
send_mail(
|
||||
subject.capitalize(),
|
||||
msg_plain,
|
||||
settings.EMAIL_FROM,
|
||||
emails,
|
||||
html_message=msg_html,
|
||||
fail_silently=False,
|
||||
)
|
||||
except smtplib.SMTPException as exception:
|
||||
logger.error("notification to %s was not sent: %s", emails, exception)
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
@staticmethod
|
||||
def _notify_summary_service(recording):
|
||||
"""Notify summary service about a new recording."""
|
||||
@@ -57,10 +119,8 @@ class NotificationService:
|
||||
logger.error("No owner found for recording %s", recording.id)
|
||||
return False
|
||||
|
||||
key = f"{settings.RECORDING_OUTPUT_FOLDER}/{recording.id}.ogg"
|
||||
|
||||
payload = {
|
||||
"filename": key,
|
||||
"filename": recording.key,
|
||||
"email": owner_access.user.email,
|
||||
"sub": owner_access.user.sub,
|
||||
}
|
||||
|
||||
@@ -12,7 +12,12 @@ from django.conf import settings
|
||||
from django.core.cache import cache
|
||||
|
||||
from asgiref.sync import async_to_sync
|
||||
from livekit.api import LiveKitAPI, SendDataRequest, TwirpError # pylint: disable=E0611
|
||||
from livekit.api import ( # pylint: disable=E0611
|
||||
ListRoomsRequest,
|
||||
LiveKitAPI,
|
||||
SendDataRequest,
|
||||
TwirpError,
|
||||
)
|
||||
|
||||
from core import models, utils
|
||||
|
||||
@@ -221,6 +226,12 @@ class LobbyService:
|
||||
color=color,
|
||||
)
|
||||
|
||||
try:
|
||||
self.notify_participants(room_id=room_id)
|
||||
except LobbyNotificationError:
|
||||
# If room not created yet, there is no participants to notify
|
||||
pass
|
||||
|
||||
cache_key = self._get_cache_key(room_id, participant_id)
|
||||
cache.set(
|
||||
cache_key,
|
||||
@@ -228,12 +239,6 @@ class LobbyService:
|
||||
timeout=settings.LOBBY_WAITING_TIMEOUT,
|
||||
)
|
||||
|
||||
try:
|
||||
self.notify_participants(room_id=room_id)
|
||||
except LobbyNotificationError:
|
||||
# If room not created yet, there is no participants to notify
|
||||
pass
|
||||
|
||||
return participant
|
||||
|
||||
def _get_participant(
|
||||
@@ -343,7 +348,18 @@ class LobbyService:
|
||||
}
|
||||
|
||||
lkapi = LiveKitAPI(**settings.LIVEKIT_CONFIGURATION)
|
||||
|
||||
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),
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
"""Room creation service."""
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.cache import cache
|
||||
|
||||
|
||||
class RoomCreation:
|
||||
"""Room creation related methods"""
|
||||
|
||||
@staticmethod
|
||||
def _get_cache_key(callback_id):
|
||||
"""Generate a standardized cache key for room creation callbacks."""
|
||||
return f"room-creation-callback_{callback_id}"
|
||||
|
||||
def persist_callback_state(self, callback_id: str, room) -> None:
|
||||
"""Store room data in cache using the callback ID as an identifier."""
|
||||
data = {
|
||||
"slug": room.slug,
|
||||
}
|
||||
cache.set(
|
||||
self._get_cache_key(callback_id),
|
||||
data,
|
||||
timeout=settings.ROOM_CREATION_CALLBACK_CACHE_TIMEOUT,
|
||||
)
|
||||
|
||||
def get_callback_state(self, callback_id: str) -> dict:
|
||||
"""Retrieve and clear cached room data for the given callback ID."""
|
||||
|
||||
cache_key = self._get_cache_key(callback_id)
|
||||
data = cache.get(cache_key)
|
||||
|
||||
if not data:
|
||||
return {}
|
||||
|
||||
cache.delete(cache_key)
|
||||
|
||||
return data
|
||||
@@ -1,14 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Generate Document</title>
|
||||
</head>
|
||||
<body>
|
||||
<h2>Generate Document</h2>
|
||||
<form method="post" enctype="multipart/form-data">
|
||||
{% csrf_token %}
|
||||
{{ form.as_p }}
|
||||
<button type="submit">Generate PDF</button>
|
||||
</form>
|
||||
</body>
|
||||
</html>
|
||||
@@ -2,6 +2,9 @@
|
||||
Test rooms API endpoints in the Meet core app: create.
|
||||
"""
|
||||
|
||||
# pylint: disable=W0621,W0613
|
||||
from django.core.cache import cache
|
||||
|
||||
import pytest
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
@@ -11,6 +14,15 @@ from ...models import Room
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def reset_cache():
|
||||
"""Provide cache cleanup after each test to maintain test isolation."""
|
||||
yield
|
||||
keys = cache.keys("room-creation-callback_*")
|
||||
if keys:
|
||||
cache.delete(*keys)
|
||||
|
||||
|
||||
def test_api_rooms_create_anonymous():
|
||||
"""Anonymous users should not be allowed to create rooms."""
|
||||
client = APIClient()
|
||||
@@ -26,7 +38,7 @@ def test_api_rooms_create_anonymous():
|
||||
assert Room.objects.exists() is False
|
||||
|
||||
|
||||
def test_api_rooms_create_authenticated():
|
||||
def test_api_rooms_create_authenticated(reset_cache):
|
||||
"""
|
||||
Authenticated users should be able to create rooms and should automatically be declared
|
||||
as owner of the newly created room.
|
||||
@@ -49,6 +61,33 @@ def test_api_rooms_create_authenticated():
|
||||
assert room.slug == "my-room"
|
||||
assert room.accesses.filter(role="owner", user=user).exists() is True
|
||||
|
||||
rooms_data = cache.keys("room-creation-callback_*")
|
||||
assert not rooms_data
|
||||
|
||||
|
||||
def test_api_rooms_create_generation_cache(reset_cache):
|
||||
"""
|
||||
Authenticated users creating a room with a callback ID should have room data stored in cache.
|
||||
"""
|
||||
user = UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.post(
|
||||
"/api/v1.0/rooms/",
|
||||
{"name": "my room", "callback_id": "1234"},
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
room = Room.objects.get()
|
||||
assert room.name == "my room"
|
||||
assert room.slug == "my-room"
|
||||
assert room.accesses.filter(role="owner", user=user).exists() is True
|
||||
|
||||
room_data = cache.get("room-creation-callback_1234")
|
||||
assert room_data.get("slug") == "my-room"
|
||||
|
||||
|
||||
def test_api_rooms_create_authenticated_existing_slug():
|
||||
"""
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
"""
|
||||
Test rooms API endpoints in the Meet core app: creation callback functionality.
|
||||
"""
|
||||
|
||||
# pylint: disable=W0621,W0613
|
||||
from django.core.cache import cache
|
||||
|
||||
import pytest
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from ...factories import UserFactory
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
# Tests for creation_callback endpoint
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def reset_cache():
|
||||
"""Provide cache cleanup after each test to maintain test isolation."""
|
||||
yield
|
||||
keys = cache.keys("room-creation-callback_*")
|
||||
if keys:
|
||||
cache.delete(*keys)
|
||||
|
||||
|
||||
def test_api_rooms_create_anonymous(reset_cache):
|
||||
"""Anonymous user can retrieve room data once using a valid callback ID."""
|
||||
client = APIClient()
|
||||
cache.set("room-creation-callback_123", {"slug": "my room"})
|
||||
response = client.post(
|
||||
"/api/v1.0/rooms/creation-callback/",
|
||||
{
|
||||
"callback_id": "123",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"status": "success", "room": {"slug": "my room"}}
|
||||
|
||||
# Data should be cleared after retrieval
|
||||
response = client.post(
|
||||
"/api/v1.0/rooms/creation-callback/",
|
||||
{
|
||||
"callback_id": "123",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"status": "success", "room": {}}
|
||||
|
||||
|
||||
def test_api_rooms_create_empty_cache():
|
||||
"""Valid callback ID return empty room data when nothing is stored in the cache."""
|
||||
client = APIClient()
|
||||
|
||||
response = client.post(
|
||||
"/api/v1.0/rooms/creation-callback/",
|
||||
{
|
||||
"callback_id": "123",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"status": "success", "room": {}}
|
||||
|
||||
|
||||
def test_api_rooms_create_missing_callback_id():
|
||||
"""Requests without a callback ID properly fail with a 400 status code."""
|
||||
client = APIClient()
|
||||
|
||||
response = client.post(
|
||||
"/api/v1.0/rooms/creation-callback/",
|
||||
{},
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
|
||||
|
||||
def test_api_rooms_create_authenticated(reset_cache):
|
||||
"""Authenticated users can also successfully retrieve room data using a valid callback ID"""
|
||||
user = UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
cache.set("room-creation-callback_123", {"slug": "my room"})
|
||||
|
||||
response = client.post(
|
||||
"/api/v1.0/rooms/creation-callback/",
|
||||
{
|
||||
"callback_id": "123",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"status": "success", "room": {"slug": "my room"}}
|
||||
@@ -776,6 +776,43 @@ def test_update_participant_status_success(mock_cache, lobby_service, participan
|
||||
lobby_service._get_cache_key.assert_called_once_with(room.id, participant_id)
|
||||
|
||||
|
||||
@mock.patch("core.services.lobby.LiveKitAPI")
|
||||
def test_notify_participants_success_no_room(mock_livekit_api, lobby_service):
|
||||
"""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_livekit_api.return_value = mock_api_instance
|
||||
|
||||
# Act
|
||||
lobby_service.notify_participants(room.id)
|
||||
|
||||
# Assert
|
||||
# Verify the API was initialized with correct configuration
|
||||
mock_livekit_api.assert_called_once_with(**settings.LIVEKIT_CONFIGURATION)
|
||||
|
||||
# Verify that the service checked for existing rooms
|
||||
mock_api_instance.room.list_rooms.assert_called_once()
|
||||
|
||||
# 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.services.lobby.LiveKitAPI")
|
||||
def test_notify_participants_success(mock_livekit_api, lobby_service):
|
||||
"""Test successful participant notification."""
|
||||
@@ -784,6 +821,14 @@ def test_notify_participants_success(mock_livekit_api, lobby_service):
|
||||
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_livekit_api.return_value = mock_api_instance
|
||||
|
||||
@@ -793,6 +838,9 @@ def test_notify_participants_success(mock_livekit_api, lobby_service):
|
||||
# Verify the API was called correctly
|
||||
mock_livekit_api.assert_called_once_with(**settings.LIVEKIT_CONFIGURATION)
|
||||
|
||||
# Verify that the service checked for existing rooms
|
||||
mock_api_instance.room.list_rooms.assert_called_once()
|
||||
|
||||
# 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]
|
||||
@@ -817,6 +865,14 @@ def test_notify_participants_error(mock_livekit_api, lobby_service):
|
||||
mock_api_instance.room.send_data = mock.AsyncMock(
|
||||
side_effect=TwirpError(msg="test error", code=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_livekit_api.return_value = mock_api_instance
|
||||
|
||||
@@ -829,6 +885,9 @@ def test_notify_participants_error(mock_livekit_api, lobby_service):
|
||||
# Verify the API was called correctly
|
||||
mock_livekit_api.assert_called_once_with(**settings.LIVEKIT_CONFIGURATION)
|
||||
|
||||
# Verify that the service checked for existing rooms
|
||||
mock_api_instance.room.list_rooms.assert_called_once()
|
||||
|
||||
# Verify send_data was called
|
||||
mock_api_instance.room.send_data.assert_called_once()
|
||||
|
||||
|
||||
@@ -14,6 +14,10 @@ from django.conf import settings
|
||||
|
||||
from livekit.api import AccessToken, VideoGrants
|
||||
|
||||
from django.core.files.storage import default_storage
|
||||
|
||||
import botocore
|
||||
|
||||
|
||||
def generate_color(identity: str) -> str:
|
||||
"""Generates a consistent HSL color based on a given identity string.
|
||||
@@ -110,3 +114,34 @@ def generate_livekit_config(
|
||||
room=room_id, user=user, username=username, color=color
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def generate_s3_authorization_headers(key):
|
||||
"""
|
||||
Generate authorization headers for an s3 object.
|
||||
These headers can be used as an alternative to signed urls with many benefits:
|
||||
- the urls of our files never expire and can be stored in our recording' metadata
|
||||
- we don't leak authorized urls that could be shared (file access can only be done
|
||||
with cookies)
|
||||
- access control is truly realtime
|
||||
- the object storage service does not need to be exposed on internet
|
||||
"""
|
||||
|
||||
|
||||
url = default_storage.unsigned_connection.meta.client.generate_presigned_url(
|
||||
"get_object",
|
||||
ExpiresIn=0,
|
||||
Params={"Bucket": default_storage.bucket_name, "Key": key},
|
||||
)
|
||||
|
||||
request = botocore.awsrequest.AWSRequest(method="get", url=url)
|
||||
|
||||
s3_client = default_storage.connection.meta.client
|
||||
# pylint: disable=protected-access
|
||||
credentials = s3_client._request_signer._credentials # noqa: SLF001
|
||||
frozen_credentials = credentials.get_frozen_credentials()
|
||||
region = s3_client.meta.region_name
|
||||
auth = botocore.auth.S3SigV4Auth(frozen_credentials, "s3", region)
|
||||
auth.add_auth(request)
|
||||
|
||||
return request
|
||||
|
||||
@@ -19,6 +19,7 @@ from django.utils.translation import gettext_lazy as _
|
||||
import sentry_sdk
|
||||
from configurations import Configuration, values
|
||||
from sentry_sdk.integrations.django import DjangoIntegration
|
||||
from sentry_sdk.integrations.logging import ignore_logger
|
||||
|
||||
# Build paths inside the project like this: BASE_DIR / 'subdir'.
|
||||
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
@@ -271,6 +272,11 @@ class Base(Configuration):
|
||||
environ_name="REQUEST_ENTRY_THROTTLE_RATES",
|
||||
environ_prefix=None,
|
||||
),
|
||||
"creation_callback": values.Value(
|
||||
default="600/minute",
|
||||
environ_name="CREATION_CALLBACK_THROTTLE_RATES",
|
||||
environ_prefix=None,
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
@@ -312,9 +318,11 @@ class Base(Configuration):
|
||||
EMAIL_USE_TLS = values.BooleanValue(False)
|
||||
EMAIL_USE_SSL = values.BooleanValue(False)
|
||||
EMAIL_FROM = values.Value("from@example.com")
|
||||
EMAIL_BRAND_NAME = values.Value(None)
|
||||
EMAIL_SUPPORT_EMAIL = values.Value(None)
|
||||
EMAIL_LOGO_IMG = values.Value(None)
|
||||
|
||||
AUTH_USER_MODEL = "core.User"
|
||||
INVITATION_VALIDITY_DURATION = 604800 # 7 days, in seconds
|
||||
|
||||
# CORS
|
||||
CORS_ALLOW_CREDENTIALS = True
|
||||
@@ -498,6 +506,9 @@ class Base(Configuration):
|
||||
converter=lambda x: int(x), # pylint: disable=unnecessary-lambda
|
||||
)
|
||||
BREVO_API_CONTACT_ATTRIBUTES = values.DictValue({"VISIO_USER": True})
|
||||
BREVO_API_TIMEOUT = values.PositiveIntegerValue(
|
||||
1, environ_name="BREVO_API_TIMEOUT", environ_prefix=None
|
||||
)
|
||||
|
||||
# Lobby configurations
|
||||
LOBBY_KEY_PREFIX = values.Value(
|
||||
@@ -525,6 +536,13 @@ class Base(Configuration):
|
||||
environ_prefix=None,
|
||||
)
|
||||
|
||||
# Calendar integrations
|
||||
ROOM_CREATION_CALLBACK_CACHE_TIMEOUT = values.PositiveIntegerValue(
|
||||
600, # 10 minutes
|
||||
environ_name="ROOM_CREATION_CALLBACK_CACHE_TIMEOUT",
|
||||
environ_prefix=None,
|
||||
)
|
||||
|
||||
# pylint: disable=invalid-name
|
||||
@property
|
||||
def ENVIRONMENT(self):
|
||||
@@ -576,6 +594,9 @@ class Base(Configuration):
|
||||
scope = sentry_sdk.get_global_scope()
|
||||
scope.set_tag("application", "backend")
|
||||
|
||||
# Ignore the logs added by the DockerflowMiddleware
|
||||
ignore_logger("request.summary")
|
||||
|
||||
|
||||
class Build(Base):
|
||||
"""Settings used when the application is built.
|
||||
|
||||
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "meet"
|
||||
version = "0.1.15"
|
||||
version = "0.1.17"
|
||||
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
|
||||
classifiers = [
|
||||
"Development Status :: 5 - Production/Stable",
|
||||
@@ -25,7 +25,7 @@ license = { file = "LICENSE" }
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
dependencies = [
|
||||
"boto3==1.37.4",
|
||||
"boto3==1.37.18",
|
||||
"Brotli==1.1.0",
|
||||
"brevo-python==1.1.2",
|
||||
"celery[redis]==5.4.0",
|
||||
@@ -37,7 +37,7 @@ dependencies = [
|
||||
"django-redis==5.4.0",
|
||||
"django-storages[s3]==1.14.5",
|
||||
"django-timezone-field>=5.1",
|
||||
"django==5.1.7",
|
||||
"django==5.1.8",
|
||||
"djangorestframework==3.15.2",
|
||||
"drf_spectacular==0.28.0",
|
||||
"dockerflow==2024.4.2",
|
||||
@@ -48,17 +48,16 @@ dependencies = [
|
||||
"june-analytics-python==2.3.0",
|
||||
"markdown==3.7",
|
||||
"nested-multipart-parser==1.5.0",
|
||||
"psycopg[binary]==3.2.5",
|
||||
"psycopg[binary]==3.2.6",
|
||||
"PyJWT==2.10.1",
|
||||
"python-frontmatter==1.1.0",
|
||||
"requests==2.32.3",
|
||||
"sentry-sdk==2.22.0",
|
||||
"sentry-sdk==2.24.1",
|
||||
"url-normalize==1.4.3",
|
||||
"WeasyPrint>=60.2",
|
||||
"whitenoise==6.9.0",
|
||||
"mozilla-django-oidc==4.0.1",
|
||||
"livekit-api==0.8.2",
|
||||
"aiohttp==3.11.13",
|
||||
"aiohttp==3.11.14",
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
@@ -73,18 +72,18 @@ dev = [
|
||||
"drf-spectacular-sidecar==2025.3.1",
|
||||
"freezegun==1.5.1",
|
||||
"ipdb==0.13.13",
|
||||
"ipython==9.0.1",
|
||||
"pyfakefs==5.7.4",
|
||||
"ipython==9.0.2",
|
||||
"pyfakefs==5.8.0",
|
||||
"pylint-django==2.6.1",
|
||||
"pylint==3.3.4",
|
||||
"pylint==3.3.6",
|
||||
"pytest-cov==6.0.0",
|
||||
"pytest-django==4.10.0",
|
||||
"pytest==8.3.5",
|
||||
"pytest-icdiff==0.9",
|
||||
"pytest-xdist==3.6.1",
|
||||
"responses==0.25.6",
|
||||
"ruff==0.9.9",
|
||||
"types-requests==2.32.0.20250301",
|
||||
"responses==0.25.7",
|
||||
"ruff==0.11.2",
|
||||
"types-requests==2.32.0.20250306",
|
||||
]
|
||||
|
||||
[tool.setuptools]
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "meet",
|
||||
"private": true,
|
||||
"version": "0.1.15",
|
||||
"version": "0.1.17",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "panda codegen && vite",
|
||||
@@ -16,45 +16,45 @@
|
||||
"@livekit/components-react": "2.8.1",
|
||||
"@livekit/components-styles": "1.1.4",
|
||||
"@livekit/track-processors": "0.3.3",
|
||||
"@pandacss/preset-panda": "0.53.0",
|
||||
"@react-aria/toast": "3.0.0-beta.19",
|
||||
"@pandacss/preset-panda": "0.53.3",
|
||||
"@react-aria/toast": "3.0.1",
|
||||
"@remixicon/react": "4.6.0",
|
||||
"@tanstack/react-query": "5.67.1",
|
||||
"@tanstack/react-query": "5.69.0",
|
||||
"crisp-sdk-web": "1.0.25",
|
||||
"hoofd": "1.7.3",
|
||||
"i18next": "24.2.2",
|
||||
"i18next": "24.2.3",
|
||||
"i18next-browser-languagedetector": "8.0.4",
|
||||
"i18next-parser": "9.3.0",
|
||||
"i18next-resources-to-backend": "1.2.1",
|
||||
"livekit-client": "2.9.5",
|
||||
"posthog-js": "1.225.1",
|
||||
"livekit-client": "2.9.8",
|
||||
"posthog-js": "1.232.6",
|
||||
"react": "18.3.1",
|
||||
"react-aria-components": "1.6.0",
|
||||
"react-aria-components": "1.7.1",
|
||||
"react-dom": "18.3.1",
|
||||
"react-i18next": "15.1.1",
|
||||
"use-sound": "5.0.0",
|
||||
"valtio": "2.1.3",
|
||||
"valtio": "2.1.4",
|
||||
"wouter": "3.6.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@pandacss/dev": "0.53.0",
|
||||
"@tanstack/eslint-plugin-query": "5.66.1",
|
||||
"@tanstack/react-query-devtools": "5.67.1",
|
||||
"@types/node": "22.13.9",
|
||||
"@pandacss/dev": "0.53.3",
|
||||
"@tanstack/eslint-plugin-query": "5.68.0",
|
||||
"@tanstack/react-query-devtools": "5.69.0",
|
||||
"@types/node": "22.13.13",
|
||||
"@types/react": "18.3.12",
|
||||
"@types/react-dom": "18.3.1",
|
||||
"@typescript-eslint/eslint-plugin": "8.26.0",
|
||||
"@typescript-eslint/parser": "8.26.0",
|
||||
"@typescript-eslint/eslint-plugin": "8.28.0",
|
||||
"@typescript-eslint/parser": "8.28.0",
|
||||
"@vitejs/plugin-react": "4.3.4",
|
||||
"eslint": "8.57.0",
|
||||
"eslint-config-prettier": "10.0.2",
|
||||
"eslint-config-prettier": "10.1.1",
|
||||
"eslint-plugin-jsx-a11y": "6.10.2",
|
||||
"eslint-plugin-react-hooks": "5.2.0",
|
||||
"eslint-plugin-react-refresh": "0.4.19",
|
||||
"postcss": "8.5.3",
|
||||
"prettier": "3.5.3",
|
||||
"typescript": "5.8.2",
|
||||
"vite": "6.2.0",
|
||||
"vite": "6.2.4",
|
||||
"vite-tsconfig-paths": "5.1.4"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,40 +35,7 @@ const config: Config = {
|
||||
exclude: [],
|
||||
jsxFramework: 'react',
|
||||
outdir: 'src/styled-system',
|
||||
globalFontface: {
|
||||
Marianne: [
|
||||
{
|
||||
src: 'url(/fonts/Marianne-Regular-subset.woff2) format("woff2")',
|
||||
fontWeight: 400,
|
||||
fontStyle: 'normal',
|
||||
fontDisplay: 'swap',
|
||||
},
|
||||
{
|
||||
path: 'url(/fonts/Marianne-Regular_Italic-subset.woff2) format("woff2")',
|
||||
fontWeight: 400,
|
||||
fontStyle: 'italic',
|
||||
fontDisplay: 'swap',
|
||||
},
|
||||
{
|
||||
path: 'url(/fonts/Marianne-Medium-subset.woff2) format("woff2")',
|
||||
fontWeight: 500,
|
||||
fontStyle: 'normal',
|
||||
fontDisplay: 'swap',
|
||||
},
|
||||
{
|
||||
path: 'url(/fonts/Marianne-Bold-subset.woff2) format("woff2")',
|
||||
fontWeight: 700,
|
||||
fontStyle: 'normal',
|
||||
fontDisplay: 'swap',
|
||||
},
|
||||
{
|
||||
path: 'url(/fonts/Marianne-ExtraBold-subset.woff2) format("woff2")',
|
||||
fontWeight: 800,
|
||||
fontStyle: 'normal',
|
||||
fontDisplay: 'swap',
|
||||
},
|
||||
],
|
||||
},
|
||||
globalFontface: {},
|
||||
theme: {
|
||||
...pandaPreset.theme,
|
||||
// media queries are defined in em so that zooming with text-only mode triggers breakpoints
|
||||
@@ -223,6 +190,7 @@ const config: Config = {
|
||||
fonts: {
|
||||
sans: {
|
||||
value: [
|
||||
'Marianne',
|
||||
'Source Sans',
|
||||
'Source Sans fallback',
|
||||
'ui-sans-serif',
|
||||
|
||||
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 457 B |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 27 KiB |
@@ -5,7 +5,7 @@ import { ReactQueryDevtools } from '@tanstack/react-query-devtools'
|
||||
import { QueryClientProvider } from '@tanstack/react-query'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useLang } from 'hoofd'
|
||||
import { Switch, Route, useLocation } from 'wouter'
|
||||
import { Switch, Route } from 'wouter'
|
||||
import { I18nProvider } from 'react-aria-components'
|
||||
import { Layout } from './layout/Layout'
|
||||
import { NotFoundScreen } from './components/NotFoundScreen'
|
||||
@@ -13,35 +13,11 @@ import { routes } from './routes'
|
||||
import './i18n/init'
|
||||
import { queryClient } from '@/api/queryClient'
|
||||
import { AppInitialization } from '@/components/AppInitialization'
|
||||
import { SdkCreateButton } from './features/sdk/routes/CreateButton'
|
||||
|
||||
const SDK_BASE_ROUTE = '/sdk'
|
||||
|
||||
function App() {
|
||||
const { i18n } = useTranslation()
|
||||
useLang(i18n.language)
|
||||
|
||||
const [location] = useLocation()
|
||||
const isSDKRoute = location.startsWith(SDK_BASE_ROUTE)
|
||||
|
||||
if (isSDKRoute) {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Suspense fallback={null}>
|
||||
<I18nProvider locale={i18n.language}>
|
||||
<Switch>
|
||||
<Route path={SDK_BASE_ROUTE} nest>
|
||||
<Route path="/create-button">
|
||||
<SdkCreateButton />
|
||||
</Route>
|
||||
</Route>
|
||||
</Switch>
|
||||
</I18nProvider>
|
||||
</Suspense>
|
||||
</QueryClientProvider>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<AppInitialization />
|
||||
@@ -55,7 +31,10 @@ function App() {
|
||||
<Route component={NotFoundScreen} />
|
||||
</Switch>
|
||||
</Layout>
|
||||
<ReactQueryDevtools initialIsOpen={false} buttonPosition="top-left" />
|
||||
<ReactQueryDevtools
|
||||
initialIsOpen={false}
|
||||
buttonPosition="bottom-left"
|
||||
/>
|
||||
</I18nProvider>
|
||||
</Suspense>
|
||||
</QueryClientProvider>
|
||||
|
||||
@@ -4,4 +4,5 @@ export const keys = {
|
||||
config: 'config',
|
||||
requestEntry: 'requestEntry',
|
||||
waitingParticipants: 'waitingParticipants',
|
||||
roomCreationCallback: 'roomCreationCallback',
|
||||
}
|
||||
|
||||
|
After Width: | Height: | Size: 704 KiB |
@@ -2,9 +2,13 @@ import { silenceLiveKitLogs } from '@/utils/livekit'
|
||||
import { useConfig } from '@/api/useConfig'
|
||||
import { useAnalytics } from '@/features/analytics/hooks/useAnalytics'
|
||||
import { useSupport } from '@/features/support/hooks/useSupport'
|
||||
import { useLocation } from 'wouter'
|
||||
|
||||
const SDK_BASE_ROUTE = '/sdk'
|
||||
|
||||
export const AppInitialization = () => {
|
||||
const { data } = useConfig()
|
||||
const [location] = useLocation()
|
||||
|
||||
const {
|
||||
analytics = {},
|
||||
@@ -12,8 +16,11 @@ export const AppInitialization = () => {
|
||||
silence_livekit_debug_logs = false,
|
||||
} = data || {}
|
||||
|
||||
useAnalytics(analytics)
|
||||
useSupport(support)
|
||||
const isSDKContext = location.includes(SDK_BASE_ROUTE)
|
||||
|
||||
useAnalytics({ ...analytics, isDisabled: isSDKContext })
|
||||
useSupport({ ...support, isDisabled: isSDKContext })
|
||||
|
||||
silenceLiveKitLogs(silence_livekit_debug_logs)
|
||||
|
||||
return null
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { cva, RecipeVariantProps } from '@/styled-system/css'
|
||||
import { css, cva, RecipeVariantProps } from '@/styled-system/css'
|
||||
import React from 'react'
|
||||
|
||||
const avatar = cva({
|
||||
@@ -19,7 +19,8 @@ const avatar = cva({
|
||||
list: {
|
||||
width: '32px',
|
||||
height: '32px',
|
||||
fontSize: '1.25rem',
|
||||
fontSize: '1.3rem',
|
||||
lineHeight: '1rem',
|
||||
},
|
||||
placeholder: {
|
||||
width: '100%',
|
||||
@@ -60,7 +61,13 @@ export const Avatar = ({
|
||||
className={avatar({ context, notification })}
|
||||
{...props}
|
||||
>
|
||||
{initial}
|
||||
<span
|
||||
className={css({
|
||||
marginTop: '-0.3rem',
|
||||
})}
|
||||
>
|
||||
{initial}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { css } from '@/styled-system/css'
|
||||
import { RiErrorWarningLine, RiExternalLinkLine } from '@remixicon/react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Text, A } from '@/primitives'
|
||||
import { GRIST_FORM } from '@/utils/constants'
|
||||
import { GRIST_FEEDBACKS_FORM } from '@/utils/constants'
|
||||
|
||||
export const FeedbackBanner = () => {
|
||||
const { t } = useTranslation()
|
||||
@@ -25,7 +25,9 @@ export const FeedbackBanner = () => {
|
||||
})}
|
||||
>
|
||||
<RiErrorWarningLine size={20} aria-hidden="true" />
|
||||
<Text as="p">{t('feedback.context')}</Text>
|
||||
<Text as="p" variant="sm">
|
||||
{t('feedback.context')}
|
||||
</Text>
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
@@ -33,7 +35,7 @@ export const FeedbackBanner = () => {
|
||||
gap: 0.25,
|
||||
})}
|
||||
>
|
||||
<A href={GRIST_FORM} target="_blank">
|
||||
<A href={GRIST_FEEDBACKS_FORM} target="_blank" size="sm">
|
||||
{t('feedback.cta')}
|
||||
</A>
|
||||
<RiExternalLinkLine size={16} aria-hidden="true" />
|
||||
|
||||
@@ -17,18 +17,19 @@ export const terminateAnalyticsSession = () => {
|
||||
export type useAnalyticsProps = {
|
||||
id?: string
|
||||
host?: string
|
||||
isDisabled?: boolean
|
||||
}
|
||||
|
||||
export const useAnalytics = ({ id, host }: useAnalyticsProps) => {
|
||||
export const useAnalytics = ({ id, host, isDisabled }: useAnalyticsProps) => {
|
||||
const [location] = useLocation()
|
||||
useEffect(() => {
|
||||
if (!id || !host) return
|
||||
if (!id || !host || isDisabled) return
|
||||
if (posthog.__loaded) return
|
||||
posthog.init(id, {
|
||||
api_host: host,
|
||||
person_profiles: 'always',
|
||||
})
|
||||
}, [id, host])
|
||||
}, [id, host, isDisabled])
|
||||
|
||||
// From PostHog tutorial on PageView tracking in a Single Page Application (SPA) context.
|
||||
useEffect(() => {
|
||||
|
||||
@@ -48,7 +48,7 @@ export const useUser = (
|
||||
const isLoggedOut = isLoggedIn === false
|
||||
|
||||
return {
|
||||
...query,
|
||||
refetch: query.refetch,
|
||||
user: isLoggedOut ? undefined : (query.data as ApiUser | undefined),
|
||||
isLoggedIn,
|
||||
logout,
|
||||
|
||||
@@ -8,21 +8,19 @@ import { Button, LinkButton } from '@/primitives'
|
||||
import { RiArrowLeftSLine, RiArrowRightSLine } from '@remixicon/react'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
// todo - extract in a proper env variable
|
||||
const BETA_USERS_FORM_URL =
|
||||
'https://grist.numerique.gouv.fr/o/docs/forms/3fFfvJoTBEQ6ZiMi8zsQwX/17'
|
||||
import { BETA_USERS_FORM_URL } from '@/utils/constants'
|
||||
|
||||
const Heading = styled('h2', {
|
||||
base: {
|
||||
width: 'fit-content',
|
||||
marginBottom: 0,
|
||||
fontSize: '1.5rem',
|
||||
fontSize: '1.3rem',
|
||||
fontWeight: '700',
|
||||
marginTop: '0.75rem',
|
||||
lineHeight: '2rem',
|
||||
lineHeight: '1.7rem',
|
||||
maxWidth: '23rem',
|
||||
textAlign: 'center',
|
||||
textWrap: 'pretty',
|
||||
textWrap: 'balance',
|
||||
},
|
||||
})
|
||||
|
||||
@@ -31,7 +29,7 @@ const Body = styled('p', {
|
||||
maxWidth: '23rem',
|
||||
textAlign: 'center',
|
||||
textWrap: 'pretty',
|
||||
lineHeight: '1.2rem',
|
||||
lineHeight: '1.4rem',
|
||||
fontSize: '1rem',
|
||||
},
|
||||
})
|
||||
@@ -102,7 +100,7 @@ const Slide = styled('div', {
|
||||
alignItems: 'center',
|
||||
gap: '0.5rem',
|
||||
justifyContent: 'start',
|
||||
minHeight: { base: 'none', xsm: '550px' },
|
||||
minHeight: { base: 'none', xsm: '580px' },
|
||||
minWidth: { base: 'none', xsm: '200px' },
|
||||
width: { base: '100%', xsm: '22.625rem' },
|
||||
},
|
||||
@@ -206,6 +204,7 @@ export const IntroSlider = () => {
|
||||
{slide.isAvailableInBeta && (
|
||||
<LinkButton
|
||||
href={BETA_USERS_FORM_URL}
|
||||
target="_blank"
|
||||
tooltip={t('beta.tooltip')}
|
||||
variant={'primary'}
|
||||
size={'sm'}
|
||||
|
||||
@@ -13,12 +13,11 @@ import { RiAddLine, RiLink } from '@remixicon/react'
|
||||
import { LaterMeetingDialog } from '@/features/home/components/LaterMeetingDialog'
|
||||
import { IntroSlider } from '@/features/home/components/IntroSlider'
|
||||
import { MoreLink } from '@/features/home/components/MoreLink'
|
||||
import { ReactNode, useEffect, useState } from 'react'
|
||||
import { ReactNode, useState } from 'react'
|
||||
|
||||
import { css } from '@/styled-system/css'
|
||||
import { menuRecipe } from '@/primitives/menuRecipe.ts'
|
||||
import { usePersistentUserChoices } from '@/features/rooms/livekit/hooks/usePersistentUserChoices'
|
||||
import { SdkReverseClient } from '@/features/sdk/SdkReverseClient'
|
||||
|
||||
const Columns = ({ children }: { children?: ReactNode }) => {
|
||||
return (
|
||||
@@ -72,8 +71,8 @@ const LeftColumn = ({ children }: { children?: ReactNode }) => {
|
||||
textAlign: 'left',
|
||||
alignItems: 'flex-start',
|
||||
flexShrink: '1',
|
||||
flexBasis: '36rem',
|
||||
maxWidth: '36rem',
|
||||
flexBasis: '40rem',
|
||||
maxWidth: '40rem',
|
||||
padding: '1em 3em',
|
||||
},
|
||||
})}
|
||||
@@ -120,14 +119,13 @@ const Separator = styled('div', {
|
||||
|
||||
const Heading = styled('h1', {
|
||||
base: {
|
||||
fontWeight: '500',
|
||||
fontWeight: '700',
|
||||
fontStyle: 'normal',
|
||||
fontStretch: 'normal',
|
||||
fontOpticalSizing: 'auto',
|
||||
marginBottom: 0,
|
||||
paddingBottom: '0.75rem',
|
||||
paddingBottom: '1.2rem',
|
||||
fontSize: '2.3rem',
|
||||
lineHeight: '2.5rem',
|
||||
lineHeight: '2.6rem',
|
||||
letterSpacing: '0',
|
||||
xsm: {
|
||||
fontSize: '3rem',
|
||||
@@ -139,9 +137,9 @@ const Heading = styled('h1', {
|
||||
const IntroText = styled('div', {
|
||||
base: {
|
||||
marginBottom: '3rem',
|
||||
fontSize: '1.5rem',
|
||||
lineHeight: '1.8rem',
|
||||
textWrap: 'pretty',
|
||||
fontSize: '1.2rem',
|
||||
lineHeight: '1.5rem',
|
||||
textWrap: 'balance',
|
||||
maxWidth: '32rem',
|
||||
},
|
||||
})
|
||||
@@ -157,18 +155,6 @@ export const Home = () => {
|
||||
const { mutateAsync: createRoom } = useCreateRoom()
|
||||
const [laterRoomId, setLaterRoomId] = useState<null | string>(null)
|
||||
|
||||
const { user } = useUser()
|
||||
|
||||
/**
|
||||
* Used for SDK popup to close automatically.
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!user) {
|
||||
return
|
||||
}
|
||||
SdkReverseClient.broadcastAuthentication()
|
||||
}, [user])
|
||||
|
||||
return (
|
||||
<UserAware>
|
||||
<Screen>
|
||||
|
||||
@@ -88,6 +88,18 @@ export const MainNotificationToast = () => {
|
||||
if (notification.data?.emoji)
|
||||
handleEmoji(notification.data.emoji, participant)
|
||||
break
|
||||
case NotificationType.TranscriptionStarted:
|
||||
case NotificationType.TranscriptionStopped:
|
||||
case NotificationType.ScreenRecordingStarted:
|
||||
case NotificationType.ScreenRecordingStopped:
|
||||
toastQueue.add(
|
||||
{
|
||||
participant,
|
||||
type: notification.type,
|
||||
},
|
||||
{ timeout: NotificationDuration.ALERT }
|
||||
)
|
||||
break
|
||||
default:
|
||||
return
|
||||
}
|
||||
|
||||
@@ -6,4 +6,8 @@ export enum NotificationType {
|
||||
LowerHand = 'lowerHand',
|
||||
ReactionReceived = 'reactionReceived',
|
||||
ParticipantWaiting = 'participantWaiting',
|
||||
TranscriptionStarted = 'transcriptionStarted',
|
||||
TranscriptionStopped = 'transcriptionStopped',
|
||||
ScreenRecordingStarted = 'screenRecordingStarted',
|
||||
ScreenRecordingStopped = 'screenRecordingStopped',
|
||||
}
|
||||
|
||||
@@ -48,7 +48,7 @@ export function Toast({ state, ...props }: ToastProps) {
|
||||
return (
|
||||
<StyledToastContainer {...toastProps} ref={ref}>
|
||||
<StyledToast>
|
||||
<div {...contentProps}>{props.toast.content?.message} machine a</div>
|
||||
<div {...contentProps}>{props.toast.content?.message}</div>
|
||||
<Button square size="sm" invisible {...closeButtonProps}>
|
||||
<RiCloseLine color="white" />
|
||||
</Button>
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { useToast } from '@react-aria/toast'
|
||||
import { useMemo, useRef } from 'react'
|
||||
|
||||
import { StyledToastContainer, ToastProps } from './Toast'
|
||||
import { HStack } from '@/styled-system/jsx'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { NotificationType } from '../NotificationType'
|
||||
|
||||
export function ToastAnyRecording({ state, ...props }: ToastProps) {
|
||||
const { t } = useTranslation('notifications')
|
||||
const ref = useRef(null)
|
||||
const { toastProps, contentProps } = useToast(props, state, ref)
|
||||
const participant = props.toast.content.participant
|
||||
const type = props.toast.content.type
|
||||
|
||||
const key = useMemo(() => {
|
||||
switch (type) {
|
||||
case NotificationType.TranscriptionStarted:
|
||||
return 'transcript.started'
|
||||
case NotificationType.TranscriptionStopped:
|
||||
return 'transcript.stopped'
|
||||
case NotificationType.ScreenRecordingStarted:
|
||||
return 'screenRecording.started'
|
||||
case NotificationType.ScreenRecordingStopped:
|
||||
return 'screenRecording.stopped'
|
||||
default:
|
||||
return
|
||||
}
|
||||
}, [type])
|
||||
|
||||
if (!key) return
|
||||
|
||||
return (
|
||||
<StyledToastContainer {...toastProps} ref={ref}>
|
||||
<HStack
|
||||
justify="center"
|
||||
alignItems="center"
|
||||
{...contentProps}
|
||||
padding={14}
|
||||
gap={0}
|
||||
>
|
||||
{t(key, {
|
||||
name: participant.name,
|
||||
})}
|
||||
</HStack>
|
||||
</StyledToastContainer>
|
||||
)
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import { ToastRaised } from './ToastRaised'
|
||||
import { ToastMuted } from './ToastMuted'
|
||||
import { ToastMessageReceived } from './ToastMessageReceived'
|
||||
import { ToastLowerHand } from './ToastLowerHand'
|
||||
import { ToastAnyRecording } from './ToastAnyRecording'
|
||||
|
||||
interface ToastRegionProps extends AriaToastRegionProps {
|
||||
state: ToastState<ToastData>
|
||||
@@ -36,6 +37,12 @@ const renderToast = (
|
||||
case NotificationType.LowerHand:
|
||||
return <ToastLowerHand key={toast.key} toast={toast} state={state} />
|
||||
|
||||
case NotificationType.TranscriptionStarted:
|
||||
case NotificationType.TranscriptionStopped:
|
||||
case NotificationType.ScreenRecordingStarted:
|
||||
case NotificationType.ScreenRecordingStopped:
|
||||
return <ToastAnyRecording key={toast.key} toast={toast} state={state} />
|
||||
|
||||
default:
|
||||
return <Toast key={toast.key} toast={toast} state={state} />
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { fetchApi } from '@/api/fetchApi'
|
||||
import { ApiRoom } from '@/features/rooms/api/ApiRoom'
|
||||
import { RecordingMode } from '@/features/rooms/api/startRecording'
|
||||
|
||||
export type RecordingApi = {
|
||||
id: string
|
||||
room: Pick<ApiRoom, 'id' | 'name' | 'slug' | 'access_level'>
|
||||
created_at: string
|
||||
mode: RecordingMode
|
||||
}
|
||||
|
||||
export const fetchRecording = ({
|
||||
recordingId,
|
||||
}: {
|
||||
roomId: string
|
||||
username?: string
|
||||
}) => {
|
||||
return fetchApi<RecordingApi>(`/recordings/${recordingId}/`)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { Recording as RecordingRoute } from './routes/Recording'
|
||||
@@ -0,0 +1,107 @@
|
||||
import { Center, VStack } from '@/styled-system/jsx'
|
||||
import { Bold, Button, Field, H, Input, LinkButton, Text } from '@/primitives'
|
||||
import { Screen } from '@/layout/Screen.tsx'
|
||||
import { useParams } from 'wouter'
|
||||
import { fetchRecording } from '../api/fetchRecording'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { Spinner } from '@/primitives/Spinner'
|
||||
import { UserAware, useUser } from '@/features/auth'
|
||||
import { CenteredContent } from '@/layout/CenteredContent.tsx'
|
||||
import { useState } from 'react'
|
||||
import fourthSlide from '@/assets/intro-slider/4_record.png'
|
||||
import { css } from '@/styled-system/css'
|
||||
import { RecordingMode } from '@/features/rooms/api/startRecording.ts'
|
||||
|
||||
function formatDate(date, format = 'YYYY-MM-DD') {
|
||||
// Accept Date object or create one from string
|
||||
const dateObj = date instanceof Date ? date : new Date(date)
|
||||
|
||||
// Check if the date is valid
|
||||
if (isNaN(dateObj.getTime())) {
|
||||
return 'Invalid Date'
|
||||
}
|
||||
|
||||
const year = dateObj.getFullYear()
|
||||
const month = dateObj.getMonth() + 1 // getMonth() returns 0-11
|
||||
const day = dateObj.getDate()
|
||||
const hours = dateObj.getHours()
|
||||
const minutes = dateObj.getMinutes()
|
||||
const seconds = dateObj.getSeconds()
|
||||
|
||||
// Pad numbers with leading zeros
|
||||
const pad = (num) => String(num).padStart(2, '0')
|
||||
|
||||
// Replace format tokens with actual values
|
||||
return format
|
||||
.replace('YYYY', year)
|
||||
.replace('MM', pad(month))
|
||||
.replace('DD', pad(day))
|
||||
.replace('HH', pad(hours))
|
||||
.replace('mm', pad(minutes))
|
||||
.replace('ss', pad(seconds))
|
||||
}
|
||||
|
||||
export const Recording = () => {
|
||||
const { recordingId } = useParams()
|
||||
const { isLoggedIn } = useUser()
|
||||
|
||||
const { data, isLoading, isError } = useQuery({
|
||||
queryKey: ['recording', recordingId],
|
||||
queryFn: () => fetchRecording({ recordingId }),
|
||||
retry: false,
|
||||
})
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Screen layout="centered" footer={false}>
|
||||
<CenteredContent>
|
||||
<Spinner />
|
||||
</CenteredContent>
|
||||
</Screen>
|
||||
)
|
||||
}
|
||||
|
||||
if (isError || !isLoggedIn) {
|
||||
return (
|
||||
<Screen layout="centered" footer={false}>
|
||||
<CenteredContent title={'lien invalide'} withBackButton>
|
||||
<Text centered>
|
||||
Vous ne pouvez pas accéder à ce lien d'enregistrement
|
||||
</Text>
|
||||
</CenteredContent>
|
||||
</Screen>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<UserAware>
|
||||
<Screen layout="centered" footer={false}>
|
||||
<Center>
|
||||
<VStack>
|
||||
<img
|
||||
src={fourthSlide}
|
||||
alt={''}
|
||||
className={css({
|
||||
maxHeight: '309px',
|
||||
})}
|
||||
/>
|
||||
<H lvl={1} centered>
|
||||
Votre enregistrement est prêt !
|
||||
</H>
|
||||
<Text centered margin="md">
|
||||
Enregistrement de la réunion <b>{data.room.name}</b> du{' '}
|
||||
{formatDate(data.created_at, 'YYYY-MM-DD HH:mm')}. <br /> Cet
|
||||
enregistrement est disponible <u>pendant 7 jours</u>.
|
||||
</Text>
|
||||
<LinkButton
|
||||
href={`https://meet.127.0.0.1.nip.io/media/recordings/${data.id}.${data.mode == RecordingMode.ScreenRecording ? 'mp4' : 'ogg'}`}
|
||||
download={`${data.room.name}-${formatDate(data.created_at)}`}
|
||||
>
|
||||
Télécharger
|
||||
</LinkButton>
|
||||
</VStack>
|
||||
</Center>
|
||||
</Screen>
|
||||
</UserAware>
|
||||
)
|
||||
}
|
||||
@@ -5,17 +5,20 @@ import { ApiRoom } from './ApiRoom'
|
||||
|
||||
export interface CreateRoomParams {
|
||||
slug: string
|
||||
callbackId?: string
|
||||
username?: string
|
||||
}
|
||||
|
||||
const createRoom = ({
|
||||
slug,
|
||||
callbackId,
|
||||
username = '',
|
||||
}: CreateRoomParams): Promise<ApiRoom> => {
|
||||
return fetchApi(`rooms/?username=${encodeURIComponent(username)}`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
name: slug,
|
||||
callback_id: callbackId,
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -298,7 +298,7 @@ export const Join = ({
|
||||
case ApiLobbyStatus.TIMEOUT:
|
||||
return (
|
||||
<VStack alignItems="center" textAlign="center">
|
||||
<H lvl={1} margin={false}>
|
||||
<H lvl={1} margin={false} centered>
|
||||
{t('timeoutInvite.title')}
|
||||
</H>
|
||||
<Text as="p" variant="note">
|
||||
@@ -310,7 +310,7 @@ export const Join = ({
|
||||
case ApiLobbyStatus.DENIED:
|
||||
return (
|
||||
<VStack alignItems="center" textAlign="center">
|
||||
<H lvl={1} margin={false}>
|
||||
<H lvl={1} margin={false} centered>
|
||||
{t('denied.title')}
|
||||
</H>
|
||||
<Text as="p" variant="note">
|
||||
@@ -322,7 +322,7 @@ export const Join = ({
|
||||
case ApiLobbyStatus.WAITING:
|
||||
return (
|
||||
<VStack alignItems="center" textAlign="center">
|
||||
<H lvl={1} margin={false}>
|
||||
<H lvl={1} margin={false} centered>
|
||||
{t('waiting.title')}
|
||||
</H>
|
||||
<Text
|
||||
@@ -346,7 +346,7 @@ export const Join = ({
|
||||
}}
|
||||
>
|
||||
<VStack marginBottom={1}>
|
||||
<H lvl={1} margin="sm">
|
||||
<H lvl={1} margin="sm" centered>
|
||||
{t('heading')}
|
||||
</H>
|
||||
<Field
|
||||
|
||||
@@ -37,7 +37,14 @@ export const Admin = () => {
|
||||
flexDirection="column"
|
||||
alignItems="start"
|
||||
>
|
||||
<Text variant="note" wrap="pretty" margin="md">
|
||||
<Text
|
||||
variant="note"
|
||||
wrap="pretty"
|
||||
className={css({
|
||||
textStyle: 'sm',
|
||||
})}
|
||||
margin={'md'}
|
||||
>
|
||||
{t('description')}
|
||||
</Text>
|
||||
<RACSeparator
|
||||
@@ -68,8 +75,8 @@ export const Admin = () => {
|
||||
</Text>
|
||||
<Field
|
||||
type="radioGroup"
|
||||
label="Type d'accès à la réunion"
|
||||
aria-label="Type d'accès à la réunion"
|
||||
label={t('access.type')}
|
||||
aria-label={t('access.type')}
|
||||
labelProps={{
|
||||
className: css({
|
||||
fontSize: '1rem',
|
||||
|
||||
@@ -75,7 +75,7 @@ export function FloatingReaction({
|
||||
>
|
||||
<span
|
||||
className={css({
|
||||
lineHeight: '45px',
|
||||
lineHeight: '57px',
|
||||
})}
|
||||
>
|
||||
{emoji}
|
||||
@@ -89,9 +89,10 @@ export function FloatingReaction({
|
||||
textAlign: 'center',
|
||||
borderRadius: '20px',
|
||||
paddingX: '0.5rem',
|
||||
paddingY: '0.15rem',
|
||||
paddingBottom: '0.3125rem',
|
||||
paddingTop: '0.15rem',
|
||||
boxShadow: '0 2px 4px rgba(0, 0, 0, 0.1)',
|
||||
lineHeight: '14px',
|
||||
lineHeight: '16px',
|
||||
})}
|
||||
>
|
||||
{name}
|
||||
|
||||
@@ -1,15 +1,102 @@
|
||||
import { css } from '@/styled-system/css'
|
||||
import { RiRecordCircleLine } from '@remixicon/react'
|
||||
import { Text } from '@/primitives'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useSnapshot } from 'valtio/index'
|
||||
import { useRoomContext } from '@livekit/components-react'
|
||||
import { Spinner } from '@/primitives/Spinner'
|
||||
import { useEffect, useMemo } from 'react'
|
||||
import { Text } from '@/primitives'
|
||||
import { RemoteParticipant, RoomEvent } from 'livekit-client'
|
||||
import { decodeNotificationDataReceived } from '@/features/notifications/utils'
|
||||
import { NotificationType } from '@/features/notifications/NotificationType'
|
||||
import { RecordingStatus, recordingStore } from '@/stores/recording'
|
||||
|
||||
export const RecordingStateToast = () => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'recording' })
|
||||
|
||||
const { t } = useTranslation('rooms', {
|
||||
keyPrefix: 'recordingToast',
|
||||
})
|
||||
const room = useRoomContext()
|
||||
|
||||
if (!room?.isRecording) return
|
||||
const recordingSnap = useSnapshot(recordingStore)
|
||||
|
||||
useEffect(() => {
|
||||
if (room.isRecording && recordingSnap.status == RecordingStatus.STOPPED) {
|
||||
recordingStore.status = RecordingStatus.ANY_STARTED
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [room.isRecording])
|
||||
|
||||
useEffect(() => {
|
||||
const handleDataReceived = (
|
||||
payload: Uint8Array,
|
||||
participant?: RemoteParticipant
|
||||
) => {
|
||||
const notification = decodeNotificationDataReceived(payload)
|
||||
|
||||
if (!participant || !notification) return
|
||||
|
||||
switch (notification.type) {
|
||||
case NotificationType.TranscriptionStarted:
|
||||
recordingStore.status = RecordingStatus.TRANSCRIPT_STARTING
|
||||
break
|
||||
case NotificationType.TranscriptionStopped:
|
||||
recordingStore.status = RecordingStatus.TRANSCRIPT_STOPPING
|
||||
break
|
||||
case NotificationType.ScreenRecordingStarted:
|
||||
recordingStore.status = RecordingStatus.SCREEN_RECORDING_STARTING
|
||||
break
|
||||
case NotificationType.ScreenRecordingStopped:
|
||||
recordingStore.status = RecordingStatus.SCREEN_RECORDING_STOPPING
|
||||
break
|
||||
default:
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const handleRecordingStatusChanged = (status: boolean) => {
|
||||
if (!status) {
|
||||
recordingStore.status = RecordingStatus.STOPPED
|
||||
} else if (recordingSnap.status == RecordingStatus.TRANSCRIPT_STARTING) {
|
||||
recordingStore.status = RecordingStatus.TRANSCRIPT_STARTED
|
||||
} else if (
|
||||
recordingSnap.status == RecordingStatus.SCREEN_RECORDING_STARTING
|
||||
) {
|
||||
recordingStore.status = RecordingStatus.SCREEN_RECORDING_STARTED
|
||||
} else {
|
||||
recordingStore.status = RecordingStatus.ANY_STARTED
|
||||
}
|
||||
}
|
||||
|
||||
room.on(RoomEvent.DataReceived, handleDataReceived)
|
||||
room.on(RoomEvent.RecordingStatusChanged, handleRecordingStatusChanged)
|
||||
|
||||
return () => {
|
||||
room.off(RoomEvent.DataReceived, handleDataReceived)
|
||||
room.off(RoomEvent.RecordingStatusChanged, handleRecordingStatusChanged)
|
||||
}
|
||||
}, [room, recordingSnap])
|
||||
|
||||
const key = useMemo(() => {
|
||||
switch (recordingSnap.status) {
|
||||
case RecordingStatus.TRANSCRIPT_STARTED:
|
||||
return 'transcript.started'
|
||||
case RecordingStatus.TRANSCRIPT_STOPPING:
|
||||
return 'transcript.stopping'
|
||||
case RecordingStatus.TRANSCRIPT_STARTING:
|
||||
return 'transcript.starting'
|
||||
case RecordingStatus.SCREEN_RECORDING_STARTED:
|
||||
return 'screenRecording.started'
|
||||
case RecordingStatus.SCREEN_RECORDING_STOPPING:
|
||||
return 'screenRecording.stopping'
|
||||
case RecordingStatus.SCREEN_RECORDING_STARTING:
|
||||
return 'screenRecording.starting'
|
||||
case RecordingStatus.ANY_STARTED:
|
||||
return 'any.started'
|
||||
default:
|
||||
return
|
||||
}
|
||||
}, [recordingSnap])
|
||||
|
||||
if (!key) return
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -19,25 +106,24 @@ export const RecordingStateToast = () => {
|
||||
top: '10px',
|
||||
left: '10px',
|
||||
paddingY: '0.25rem',
|
||||
paddingX: '0.25rem 0.35rem',
|
||||
backgroundColor: 'primaryDark.200',
|
||||
borderColor: 'primaryDark.400',
|
||||
paddingX: '0.75rem 0.75rem',
|
||||
backgroundColor: 'primaryDark.100',
|
||||
borderColor: 'white',
|
||||
border: '1px solid',
|
||||
color: 'white',
|
||||
borderRadius: '4px',
|
||||
gap: '0.5rem',
|
||||
})}
|
||||
>
|
||||
<RiRecordCircleLine
|
||||
size={20}
|
||||
<Spinner size={20} variant="dark" />
|
||||
<Text
|
||||
variant={'sm'}
|
||||
className={css({
|
||||
color: 'white',
|
||||
backgroundColor: 'danger.700',
|
||||
padding: '3px',
|
||||
borderRadius: '3px',
|
||||
fontWeight: '500 !important',
|
||||
})}
|
||||
/>
|
||||
<Text variant={'sm'}>{t('label')}</Text>
|
||||
>
|
||||
{t(key)}
|
||||
</Text>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
import { A, Button, Div, Text } from '@/primitives'
|
||||
|
||||
import fourthSlide from '@/assets/intro-slider/4_record.png'
|
||||
import { css } from '@/styled-system/css'
|
||||
import { useRoomId } from '@/features/rooms/livekit/hooks/useRoomId'
|
||||
import { useRoomContext } from '@livekit/components-react'
|
||||
import {
|
||||
RecordingMode,
|
||||
useStartRecording,
|
||||
} from '@/features/rooms/api/startRecording'
|
||||
import { useStopRecording } from '@/features/rooms/api/stopRecording'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { RoomEvent } from 'livekit-client'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { NotificationPayload } from '@/features/notifications/NotificationPayload'
|
||||
import { NotificationType } from '@/features/notifications/NotificationType'
|
||||
import { RecordingStatus, recordingStore } from '@/stores/recording'
|
||||
import { CRISP_HELP_ARTICLE_RECORDING } from '@/utils/constants'
|
||||
import { useIsRecordingTransitioning } from '../hooks/useIsRecordingTransitioning'
|
||||
import {
|
||||
useIsScreenRecordingStarted,
|
||||
useIsTranscriptStarted,
|
||||
} from '../hooks/useIsRecordingStarted'
|
||||
|
||||
export const ScreenRecording = () => {
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'screenRecording' })
|
||||
|
||||
const roomId = useRoomId()
|
||||
|
||||
const { mutateAsync: startRecordingRoom } = useStartRecording()
|
||||
const { mutateAsync: stopRecordingRoom } = useStopRecording()
|
||||
|
||||
const isScreenRecordingStarted = useIsScreenRecordingStarted()
|
||||
const isTranscriptStarted = useIsTranscriptStarted()
|
||||
|
||||
const room = useRoomContext()
|
||||
const isRecordingTransitioning = useIsRecordingTransitioning()
|
||||
|
||||
useEffect(() => {
|
||||
const handleRecordingStatusChanged = () => {
|
||||
setIsLoading(false)
|
||||
}
|
||||
room.on(RoomEvent.RecordingStatusChanged, handleRecordingStatusChanged)
|
||||
return () => {
|
||||
room.off(RoomEvent.RecordingStatusChanged, handleRecordingStatusChanged)
|
||||
}
|
||||
}, [room])
|
||||
|
||||
const notifyParticipant = async (status: NotificationType) => {
|
||||
const encoder = new TextEncoder()
|
||||
const payload: NotificationPayload = {
|
||||
type: status,
|
||||
}
|
||||
const data = encoder.encode(JSON.stringify(payload))
|
||||
await room.localParticipant.publishData(data, {
|
||||
reliable: true,
|
||||
})
|
||||
}
|
||||
|
||||
const handleScreenRecording = async () => {
|
||||
if (!roomId) {
|
||||
console.warn('No room ID found')
|
||||
return
|
||||
}
|
||||
try {
|
||||
setIsLoading(true)
|
||||
if (room.isRecording) {
|
||||
await stopRecordingRoom({ id: roomId })
|
||||
await notifyParticipant(NotificationType.ScreenRecordingStopped)
|
||||
recordingStore.status = RecordingStatus.SCREEN_RECORDING_STOPPING
|
||||
} else {
|
||||
await startRecordingRoom({
|
||||
id: roomId,
|
||||
mode: RecordingMode.ScreenRecording,
|
||||
})
|
||||
await notifyParticipant(NotificationType.ScreenRecordingStarted)
|
||||
recordingStore.status = RecordingStatus.SCREEN_RECORDING_STARTING
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to handle transcript:', error)
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const isDisabled = useMemo(
|
||||
() => isLoading || isRecordingTransitioning || isTranscriptStarted,
|
||||
[isLoading, isRecordingTransitioning, isTranscriptStarted]
|
||||
)
|
||||
|
||||
return (
|
||||
<Div
|
||||
display="flex"
|
||||
overflowY="scroll"
|
||||
padding="0 1.5rem"
|
||||
flexGrow={1}
|
||||
flexDirection="column"
|
||||
alignItems="center"
|
||||
>
|
||||
<img
|
||||
src={fourthSlide}
|
||||
alt={''}
|
||||
className={css({
|
||||
minHeight: '309px',
|
||||
marginBottom: '1rem',
|
||||
})}
|
||||
/>
|
||||
|
||||
{isScreenRecordingStarted ? (
|
||||
<>
|
||||
<Text>{t('stop.heading')}</Text>
|
||||
<Text
|
||||
variant="note"
|
||||
wrap={'pretty'}
|
||||
centered
|
||||
className={css({
|
||||
textStyle: 'sm',
|
||||
marginBottom: '2.5rem',
|
||||
marginTop: '0.25rem',
|
||||
})}
|
||||
>
|
||||
{t('stop.body')}
|
||||
</Text>
|
||||
<Button
|
||||
isDisabled={isDisabled}
|
||||
onPress={() => handleScreenRecording()}
|
||||
data-attr="stop-transcript"
|
||||
size="sm"
|
||||
variant="tertiary"
|
||||
>
|
||||
{t('stop.button')}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Text>{t('start.heading')}</Text>
|
||||
<Text
|
||||
variant="note"
|
||||
wrap={'pretty'}
|
||||
centered
|
||||
className={css({
|
||||
textStyle: 'sm',
|
||||
maxWidth: '90%',
|
||||
marginBottom: '2.5rem',
|
||||
marginTop: '0.25rem',
|
||||
})}
|
||||
>
|
||||
{t('start.body')} <br />{' '}
|
||||
<A href={CRISP_HELP_ARTICLE_RECORDING} target="_blank">
|
||||
{t('start.linkMore')}
|
||||
</A>
|
||||
</Text>
|
||||
<Button
|
||||
isDisabled={isDisabled}
|
||||
onPress={() => handleScreenRecording()}
|
||||
data-attr="start-transcript"
|
||||
size="sm"
|
||||
variant="tertiary"
|
||||
>
|
||||
{t('start.button')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</Div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { A, Button, Dialog, P } from '@/primitives'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { css } from '@/styled-system/css'
|
||||
|
||||
// todo - refactor it into a generic system
|
||||
export const ScreenShareErrorModal = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
}: {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
}) => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'error.screenShare' })
|
||||
const isMac = navigator.userAgent.toLowerCase().indexOf('mac') !== -1
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
isOpen={isOpen}
|
||||
role="alertdialog"
|
||||
title={t('title')}
|
||||
aria-label={t('ariaLabel')}
|
||||
onClose={onClose}
|
||||
>
|
||||
{({ close }) => {
|
||||
return (
|
||||
<>
|
||||
<P>
|
||||
{t('message')}{' '}
|
||||
{isMac && (
|
||||
<>
|
||||
{t('macInstructions')}{' '}
|
||||
<A
|
||||
href="x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture"
|
||||
color="primary"
|
||||
aria-label={t('macSystemPreferences') + '-' + t('newTab')}
|
||||
>
|
||||
{t('macSystemPreferences')}
|
||||
</A>
|
||||
.{' '}
|
||||
</>
|
||||
)}
|
||||
{t('helpLinkText')}{' '}
|
||||
<A
|
||||
href="https://lasuite.crisp.help/fr/article/visio-probleme-de-presentation-1xkf799/"
|
||||
aria-label={t('helpLinkLabel') + '-' + t('newTab')}
|
||||
target="_blank"
|
||||
color="primary"
|
||||
>
|
||||
{t('helpLinkLabel')}
|
||||
</A>
|
||||
.
|
||||
</P>
|
||||
<Button
|
||||
onPress={close}
|
||||
size="sm"
|
||||
variant="primary"
|
||||
className={css({ marginLeft: 'auto', marginTop: '2rem' })}
|
||||
>
|
||||
{t('closeButton')}
|
||||
</Button>
|
||||
</>
|
||||
)
|
||||
}}
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -3,15 +3,15 @@ import { css } from '@/styled-system/css'
|
||||
import { Heading } from 'react-aria-components'
|
||||
import { text } from '@/primitives/Text'
|
||||
import { Button, Div } from '@/primitives'
|
||||
import { RiCloseLine } from '@remixicon/react'
|
||||
import { RiArrowLeftLine, RiCloseLine } from '@remixicon/react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { ParticipantsList } from './controls/Participants/ParticipantsList'
|
||||
import { useSidePanel } from '../hooks/useSidePanel'
|
||||
import { ReactNode } from 'react'
|
||||
import { Chat } from '../prefabs/Chat'
|
||||
import { Transcript } from './Transcript'
|
||||
import { Effects } from './effects/Effects'
|
||||
import { Admin } from './Admin'
|
||||
import { Tools } from './Tools'
|
||||
|
||||
type StyledSidePanelProps = {
|
||||
title: string
|
||||
@@ -19,6 +19,8 @@ type StyledSidePanelProps = {
|
||||
onClose: () => void
|
||||
isClosed: boolean
|
||||
closeButtonTooltip: string
|
||||
isSubmenu: boolean
|
||||
onBack: () => void
|
||||
}
|
||||
|
||||
const StyledSidePanel = ({
|
||||
@@ -27,6 +29,8 @@ const StyledSidePanel = ({
|
||||
onClose,
|
||||
isClosed,
|
||||
closeButtonTooltip,
|
||||
isSubmenu = false,
|
||||
onBack,
|
||||
}: StyledSidePanelProps) => (
|
||||
<div
|
||||
className={css({
|
||||
@@ -61,9 +65,22 @@ const StyledSidePanel = ({
|
||||
style={{
|
||||
paddingLeft: '1.5rem',
|
||||
paddingTop: '1rem',
|
||||
display: isClosed ? 'none' : undefined,
|
||||
display: isClosed ? 'none' : 'flex',
|
||||
justifyContent: 'start',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
{isSubmenu && (
|
||||
<Button
|
||||
variant="secondaryText"
|
||||
size={'sm'}
|
||||
square
|
||||
className={css({ marginRight: '0.5rem' })}
|
||||
onPress={onBack}
|
||||
>
|
||||
<RiArrowLeftLine size={20} />
|
||||
</Button>
|
||||
)}
|
||||
{title}
|
||||
</Heading>
|
||||
<Div
|
||||
@@ -114,19 +131,26 @@ export const SidePanel = () => {
|
||||
isEffectsOpen,
|
||||
isChatOpen,
|
||||
isSidePanelOpen,
|
||||
isTranscriptOpen,
|
||||
isToolsOpen,
|
||||
isAdminOpen,
|
||||
isSubPanelOpen,
|
||||
activeSubPanelId,
|
||||
} = useSidePanel()
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'sidePanel' })
|
||||
|
||||
return (
|
||||
<StyledSidePanel
|
||||
title={t(`heading.${activePanelId}`)}
|
||||
onClose={() => (layoutStore.activePanelId = null)}
|
||||
title={t(`heading.${activeSubPanelId || activePanelId}`)}
|
||||
onClose={() => {
|
||||
layoutStore.activePanelId = null
|
||||
layoutStore.activeSubPanelId = null
|
||||
}}
|
||||
closeButtonTooltip={t('closeButton', {
|
||||
content: t(`content.${activePanelId}`),
|
||||
content: t(`content.${activeSubPanelId || activePanelId}`),
|
||||
})}
|
||||
isClosed={!isSidePanelOpen}
|
||||
isSubmenu={isSubPanelOpen}
|
||||
onBack={() => (layoutStore.activeSubPanelId = null)}
|
||||
>
|
||||
<Panel isOpen={isParticipantsOpen}>
|
||||
<ParticipantsList />
|
||||
@@ -137,8 +161,8 @@ export const SidePanel = () => {
|
||||
<Panel isOpen={isChatOpen}>
|
||||
<Chat />
|
||||
</Panel>
|
||||
<Panel isOpen={isTranscriptOpen}>
|
||||
<Transcript />
|
||||
<Panel isOpen={isToolsOpen}>
|
||||
<Tools />
|
||||
</Panel>
|
||||
<Panel isOpen={isAdminOpen}>
|
||||
<Admin />
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
import { A, Div, Text } from '@/primitives'
|
||||
import { css } from '@/styled-system/css'
|
||||
import { Button as RACButton } from 'react-aria-components'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { CRISP_HELP_ARTICLE_MORE_TOOLS } from '@/utils/constants'
|
||||
import { ReactNode } from 'react'
|
||||
import { Transcript } from './Transcript'
|
||||
import { RiFileTextFill, RiLiveFill } from '@remixicon/react'
|
||||
import { SubPanelId, useSidePanel } from '../hooks/useSidePanel'
|
||||
import { ScreenRecording } from '@/features/rooms/livekit/components/ScreenRecording'
|
||||
import { useIsRecordingEnabled } from '../hooks/useIsRecordingEnabled'
|
||||
import { RecordingMode } from '@/features/rooms/api/startRecording'
|
||||
|
||||
export interface ToolsButtonProps {
|
||||
icon: ReactNode
|
||||
title: string
|
||||
description: string
|
||||
onPress: () => void
|
||||
}
|
||||
|
||||
const ToolButton = ({
|
||||
icon,
|
||||
title,
|
||||
description,
|
||||
onPress,
|
||||
}: ToolsButtonProps) => {
|
||||
return (
|
||||
<RACButton
|
||||
className={css({
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'start',
|
||||
paddingY: '0.5rem',
|
||||
paddingX: '0.75rem 1.5rem',
|
||||
borderRadius: '5px',
|
||||
gap: '1.25rem',
|
||||
width: 'full',
|
||||
textAlign: 'start',
|
||||
'&[data-hovered]': {
|
||||
backgroundColor: 'primary.50',
|
||||
cursor: 'pointer',
|
||||
},
|
||||
})}
|
||||
onPress={onPress}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
height: '50px',
|
||||
minWidth: '50px',
|
||||
borderRadius: '25px',
|
||||
backgroundColor: 'primary.800',
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
})}
|
||||
>
|
||||
{icon}
|
||||
</div>
|
||||
<div>
|
||||
<Text margin={false} as="h3">
|
||||
{title}
|
||||
</Text>
|
||||
<Text as="p" variant="smNote" wrap="pretty">
|
||||
{description}
|
||||
</Text>
|
||||
</div>
|
||||
</RACButton>
|
||||
)
|
||||
}
|
||||
|
||||
export const Tools = () => {
|
||||
const { openTranscript, openScreenRecording, activeSubPanelId } =
|
||||
useSidePanel()
|
||||
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'moreTools' })
|
||||
const isTranscriptEnabled = useIsRecordingEnabled(RecordingMode.Transcript)
|
||||
|
||||
// FIXME - use settings returned by backend
|
||||
const isRecordingEnabled = true
|
||||
|
||||
switch (activeSubPanelId) {
|
||||
case SubPanelId.TRANSCRIPT:
|
||||
return <Transcript />
|
||||
case SubPanelId.SCREEN_RECORDING:
|
||||
return <ScreenRecording />
|
||||
default:
|
||||
break
|
||||
}
|
||||
|
||||
return (
|
||||
<Div
|
||||
display="flex"
|
||||
overflowY="scroll"
|
||||
padding="0 0.75rem"
|
||||
flexGrow={1}
|
||||
flexDirection="column"
|
||||
alignItems="start"
|
||||
>
|
||||
<Text
|
||||
variant="note"
|
||||
wrap="balance"
|
||||
className={css({
|
||||
textStyle: 'sm',
|
||||
paddingX: '0.75rem',
|
||||
})}
|
||||
margin="md"
|
||||
>
|
||||
{t('body')}{' '}
|
||||
<A href={CRISP_HELP_ARTICLE_MORE_TOOLS} target="_blank">
|
||||
{t('moreLink')}
|
||||
</A>
|
||||
.
|
||||
</Text>
|
||||
{isTranscriptEnabled && (
|
||||
<ToolButton
|
||||
icon={<RiFileTextFill size={24} color="white" />}
|
||||
title={t('tools.transcript.title')}
|
||||
description={t('tools.transcript.body')}
|
||||
onPress={() => openTranscript()}
|
||||
/>
|
||||
)}
|
||||
{isRecordingEnabled && (
|
||||
<ToolButton
|
||||
icon={<RiLiveFill size={24} color="white" />}
|
||||
title={t('tools.screenRecording.title')}
|
||||
description={t('tools.screenRecording.body')}
|
||||
onPress={() => openScreenRecording()}
|
||||
/>
|
||||
)}
|
||||
</Div>
|
||||
)
|
||||
}
|
||||
@@ -1,10 +1,7 @@
|
||||
import { Button, Div, H, Text } from '@/primitives'
|
||||
import { A, Button, Div, LinkButton, Text } from '@/primitives'
|
||||
|
||||
import thirdSlide from '@/assets/intro-slider/3_resume.png'
|
||||
import { css } from '@/styled-system/css'
|
||||
|
||||
import { useHasTranscriptAccess } from '../hooks/useHasTranscriptAccess'
|
||||
import { RiRecordCircleLine, RiStopCircleLine } from '@remixicon/react'
|
||||
import { useRoomId } from '@/features/rooms/livekit/hooks/useRoomId'
|
||||
import { useRoomContext } from '@livekit/components-react'
|
||||
import {
|
||||
@@ -12,22 +9,37 @@ import {
|
||||
useStartRecording,
|
||||
} from '@/features/rooms/api/startRecording'
|
||||
import { useStopRecording } from '@/features/rooms/api/stopRecording'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { RoomEvent } from 'livekit-client'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { NotificationPayload } from '@/features/notifications/NotificationPayload'
|
||||
import { NotificationType } from '@/features/notifications/NotificationType'
|
||||
import { RecordingStatus, recordingStore } from '@/stores/recording'
|
||||
import { useHasTranscriptAccess } from '../hooks/useHasTranscriptAccess'
|
||||
import {
|
||||
BETA_USERS_FORM_URL,
|
||||
CRISP_HELP_ARTICLE_TRANSCRIPT,
|
||||
} from '@/utils/constants'
|
||||
import { useIsRecordingTransitioning } from '../hooks/useIsRecordingTransitioning'
|
||||
import {
|
||||
useIsScreenRecordingStarted,
|
||||
useIsTranscriptStarted,
|
||||
} from '../hooks/useIsRecordingStarted'
|
||||
|
||||
export const Transcript = () => {
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'transcript' })
|
||||
|
||||
const hasTranscriptAccess = useHasTranscriptAccess()
|
||||
|
||||
const roomId = useRoomId()
|
||||
|
||||
const { mutateAsync: startRecordingRoom } = useStartRecording()
|
||||
const { mutateAsync: stopRecordingRoom } = useStopRecording()
|
||||
|
||||
const isScreenRecordingStarted = useIsScreenRecordingStarted()
|
||||
const isTranscriptStarted = useIsTranscriptStarted()
|
||||
const isRecordingTransitioning = useIsRecordingTransitioning()
|
||||
|
||||
const room = useRoomContext()
|
||||
|
||||
useEffect(() => {
|
||||
@@ -40,6 +52,17 @@ export const Transcript = () => {
|
||||
}
|
||||
}, [room])
|
||||
|
||||
const notifyParticipant = async (status: NotificationType) => {
|
||||
const encoder = new TextEncoder()
|
||||
const payload: NotificationPayload = {
|
||||
type: status,
|
||||
}
|
||||
const data = encoder.encode(JSON.stringify(payload))
|
||||
await room.localParticipant.publishData(data, {
|
||||
reliable: true,
|
||||
})
|
||||
}
|
||||
|
||||
const handleTranscript = async () => {
|
||||
if (!roomId) {
|
||||
console.warn('No room ID found')
|
||||
@@ -49,8 +72,12 @@ export const Transcript = () => {
|
||||
setIsLoading(true)
|
||||
if (room.isRecording) {
|
||||
await stopRecordingRoom({ id: roomId })
|
||||
await notifyParticipant(NotificationType.TranscriptionStopped)
|
||||
recordingStore.status = RecordingStatus.TRANSCRIPT_STOPPING
|
||||
} else {
|
||||
await startRecordingRoom({ id: roomId, mode: RecordingMode.Transcript })
|
||||
await notifyParticipant(NotificationType.TranscriptionStarted)
|
||||
recordingStore.status = RecordingStatus.TRANSCRIPT_STARTING
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to handle transcript:', error)
|
||||
@@ -58,7 +85,10 @@ export const Transcript = () => {
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasTranscriptAccess) return
|
||||
const isDisabled = useMemo(
|
||||
() => isLoading || isRecordingTransitioning || isScreenRecordingStarted,
|
||||
[isLoading, isRecordingTransitioning, isScreenRecordingStarted]
|
||||
)
|
||||
|
||||
return (
|
||||
<Div
|
||||
@@ -69,38 +99,98 @@ export const Transcript = () => {
|
||||
flexDirection="column"
|
||||
alignItems="center"
|
||||
>
|
||||
<img src={thirdSlide} alt={'wip'} />
|
||||
{room.isRecording ? (
|
||||
<img
|
||||
src={thirdSlide}
|
||||
alt={''}
|
||||
className={css({
|
||||
minHeight: '309px',
|
||||
marginBottom: '1rem',
|
||||
})}
|
||||
/>
|
||||
{!hasTranscriptAccess ? (
|
||||
<>
|
||||
<H lvl={2}>{t('stop.heading')}</H>
|
||||
<Text variant="sm" centered wrap="balance">
|
||||
{t('stop.body')}
|
||||
</Text>
|
||||
<div className={css({ height: '2rem' })} />
|
||||
<Button
|
||||
isDisabled={isLoading}
|
||||
onPress={() => handleTranscript()}
|
||||
data-attr="stop-transcript"
|
||||
<Text>{t('beta.heading')}</Text>
|
||||
<Text
|
||||
variant="note"
|
||||
wrap={'pretty'}
|
||||
centered
|
||||
className={css({
|
||||
textStyle: 'sm',
|
||||
marginBottom: '2.5rem',
|
||||
marginTop: '0.25rem',
|
||||
})}
|
||||
>
|
||||
<RiStopCircleLine style={{ marginRight: '0.5rem' }} />{' '}
|
||||
{t('stop.button')}
|
||||
</Button>
|
||||
{t('beta.body')}{' '}
|
||||
<A href={CRISP_HELP_ARTICLE_TRANSCRIPT} target="_blank">
|
||||
{t('start.linkMore')}
|
||||
</A>
|
||||
</Text>
|
||||
<LinkButton
|
||||
size="sm"
|
||||
variant="tertiary"
|
||||
href={BETA_USERS_FORM_URL}
|
||||
target="_blank"
|
||||
>
|
||||
{t('beta.button')}
|
||||
</LinkButton>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<H lvl={2}>{t('start.heading')}</H>
|
||||
<Text variant="sm" centered wrap="balance">
|
||||
{t('start.body')}
|
||||
</Text>
|
||||
<div className={css({ height: '2rem' })} />
|
||||
<Button
|
||||
isDisabled={isLoading}
|
||||
onPress={() => handleTranscript()}
|
||||
data-attr="start-transcript"
|
||||
>
|
||||
<RiRecordCircleLine style={{ marginRight: '0.5rem' }} />{' '}
|
||||
{t('start.button')}
|
||||
</Button>
|
||||
{isTranscriptStarted ? (
|
||||
<>
|
||||
<Text>{t('stop.heading')}</Text>
|
||||
<Text
|
||||
variant="note"
|
||||
wrap={'pretty'}
|
||||
centered
|
||||
className={css({
|
||||
textStyle: 'sm',
|
||||
marginBottom: '2.5rem',
|
||||
marginTop: '0.25rem',
|
||||
})}
|
||||
>
|
||||
{t('stop.body')}
|
||||
</Text>
|
||||
<Button
|
||||
isDisabled={isDisabled}
|
||||
onPress={() => handleTranscript()}
|
||||
data-attr="stop-transcript"
|
||||
size="sm"
|
||||
variant="tertiary"
|
||||
>
|
||||
{t('stop.button')}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Text>{t('start.heading')}</Text>
|
||||
<Text
|
||||
variant="note"
|
||||
wrap={'pretty'}
|
||||
centered
|
||||
className={css({
|
||||
textStyle: 'sm',
|
||||
maxWidth: '90%',
|
||||
marginBottom: '2.5rem',
|
||||
marginTop: '0.25rem',
|
||||
})}
|
||||
>
|
||||
{t('start.body')} <br />{' '}
|
||||
<A href={CRISP_HELP_ARTICLE_TRANSCRIPT} target="_blank">
|
||||
{t('start.linkMore')}
|
||||
</A>
|
||||
</Text>
|
||||
<Button
|
||||
isDisabled={isDisabled}
|
||||
onPress={() => handleTranscript()}
|
||||
data-attr="start-transcript"
|
||||
size="sm"
|
||||
variant="tertiary"
|
||||
>
|
||||
{t('start.button')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Div>
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
/**
|
||||
* This is simply a wrapper around track-processor-js Processor
|
||||
* in order to be compatible with a common interface BackgroundBlurProcessorInterface
|
||||
* used accross the project.
|
||||
* used across the project.
|
||||
*/
|
||||
export class BackgroundBlurTrackProcessorJsWrapper
|
||||
implements BackgroundProcessorInterface
|
||||
|
||||
@@ -0,0 +1,345 @@
|
||||
import { ProcessorOptions, Track } from 'livekit-client'
|
||||
import posthog from 'posthog-js'
|
||||
import {
|
||||
FilesetResolver,
|
||||
FaceLandmarker,
|
||||
FaceLandmarkerResult,
|
||||
} from '@mediapipe/tasks-vision'
|
||||
import {
|
||||
CLEAR_TIMEOUT,
|
||||
SET_TIMEOUT,
|
||||
TIMEOUT_TICK,
|
||||
timerWorkerScript,
|
||||
} from './TimerWorker'
|
||||
import {
|
||||
BackgroundProcessorInterface,
|
||||
BackgroundOptions,
|
||||
ProcessorType,
|
||||
} from '.'
|
||||
|
||||
const PROCESSING_WIDTH = 256 * 3
|
||||
const PROCESSING_HEIGHT = 144 * 3
|
||||
|
||||
const FACE_LANDMARKS_CANVAS_ID = 'face-landmarks-local'
|
||||
|
||||
export class FaceLandmarksProcessor implements BackgroundProcessorInterface {
|
||||
options: BackgroundOptions
|
||||
name: string
|
||||
processedTrack?: MediaStreamTrack | undefined
|
||||
|
||||
source?: MediaStreamTrack
|
||||
sourceSettings?: MediaTrackSettings
|
||||
videoElement?: HTMLVideoElement
|
||||
videoElementLoaded?: boolean
|
||||
|
||||
// Canvas containing the video processing result
|
||||
outputCanvas?: HTMLCanvasElement
|
||||
outputCanvasCtx?: CanvasRenderingContext2D
|
||||
|
||||
faceLandmarker?: FaceLandmarker
|
||||
faceLandmarkerResult?: FaceLandmarkerResult
|
||||
|
||||
// The resized image of the video source
|
||||
sourceImageData?: ImageData
|
||||
|
||||
timerWorker?: Worker
|
||||
|
||||
type: ProcessorType
|
||||
|
||||
// Effect images
|
||||
glassesImage?: HTMLImageElement
|
||||
mustacheImage?: HTMLImageElement
|
||||
beretImage?: HTMLImageElement
|
||||
constructor(opts: BackgroundOptions) {
|
||||
this.name = 'face_landmarks'
|
||||
this.options = opts
|
||||
this.type = ProcessorType.FACE_LANDMARKS
|
||||
this._initEffectImages()
|
||||
}
|
||||
|
||||
private _initEffectImages() {
|
||||
this.glassesImage = new Image()
|
||||
this.glassesImage.src = '/assets/glasses.png'
|
||||
this.glassesImage.crossOrigin = 'anonymous'
|
||||
|
||||
this.mustacheImage = new Image()
|
||||
this.mustacheImage.src = '/assets/mustache.png'
|
||||
this.mustacheImage.crossOrigin = 'anonymous'
|
||||
|
||||
this.beretImage = new Image()
|
||||
this.beretImage.src = '/assets/beret.png'
|
||||
this.beretImage.crossOrigin = 'anonymous'
|
||||
}
|
||||
|
||||
static get isSupported() {
|
||||
return true // Face landmarks should work in all modern browsers
|
||||
}
|
||||
|
||||
async init(opts: ProcessorOptions<Track.Kind>) {
|
||||
if (!opts.element) {
|
||||
throw new Error('Element is required for processing')
|
||||
}
|
||||
|
||||
this.source = opts.track as MediaStreamTrack
|
||||
this.sourceSettings = this.source!.getSettings()
|
||||
this.videoElement = opts.element as HTMLVideoElement
|
||||
|
||||
this._createMainCanvas()
|
||||
|
||||
const stream = this.outputCanvas!.captureStream()
|
||||
const tracks = stream.getVideoTracks()
|
||||
if (tracks.length == 0) {
|
||||
throw new Error('No tracks found for processing')
|
||||
}
|
||||
this.processedTrack = tracks[0]
|
||||
|
||||
await this.initFaceLandmarker()
|
||||
this._initWorker()
|
||||
|
||||
posthog.capture('face-landmarks-init')
|
||||
}
|
||||
|
||||
_initWorker() {
|
||||
this.timerWorker = new Worker(timerWorkerScript, {
|
||||
name: 'FaceLandmarks',
|
||||
})
|
||||
this.timerWorker.onmessage = (data) => this.onTimerMessage(data)
|
||||
if (this.videoElementLoaded) {
|
||||
this.timerWorker!.postMessage({
|
||||
id: SET_TIMEOUT,
|
||||
timeMs: 1000 / 30,
|
||||
})
|
||||
} else {
|
||||
this.videoElement!.onloadeddata = () => {
|
||||
this.videoElementLoaded = true
|
||||
this.timerWorker!.postMessage({
|
||||
id: SET_TIMEOUT,
|
||||
timeMs: 1000 / 30,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onTimerMessage(response: { data: { id: number } }) {
|
||||
if (response.data.id === TIMEOUT_TICK) {
|
||||
this.process()
|
||||
}
|
||||
}
|
||||
|
||||
async initFaceLandmarker() {
|
||||
const vision = await FilesetResolver.forVisionTasks(
|
||||
'https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision/wasm'
|
||||
)
|
||||
this.faceLandmarker = await FaceLandmarker.createFromOptions(vision, {
|
||||
baseOptions: {
|
||||
modelAssetPath:
|
||||
'https://storage.googleapis.com/mediapipe-models/face_landmarker/face_landmarker/float16/latest/face_landmarker.task',
|
||||
delegate: 'GPU',
|
||||
},
|
||||
runningMode: 'VIDEO',
|
||||
outputFaceBlendshapes: true,
|
||||
outputFacialTransformationMatrixes: true,
|
||||
})
|
||||
}
|
||||
|
||||
async sizeSource() {
|
||||
this.outputCanvasCtx?.drawImage(
|
||||
this.videoElement!,
|
||||
0,
|
||||
0,
|
||||
this.videoElement!.videoWidth,
|
||||
this.videoElement!.videoHeight,
|
||||
0,
|
||||
0,
|
||||
PROCESSING_WIDTH,
|
||||
PROCESSING_HEIGHT
|
||||
)
|
||||
|
||||
this.sourceImageData = this.outputCanvasCtx?.getImageData(
|
||||
0,
|
||||
0,
|
||||
PROCESSING_WIDTH,
|
||||
PROCESSING_HEIGHT
|
||||
)
|
||||
}
|
||||
|
||||
async detectFaces() {
|
||||
const startTimeMs = performance.now()
|
||||
this.faceLandmarkerResult = this.faceLandmarker!.detectForVideo(
|
||||
this.sourceImageData!,
|
||||
startTimeMs
|
||||
)
|
||||
}
|
||||
|
||||
private drawEffect(
|
||||
leftPoint: { x: number; y: number },
|
||||
rightPoint: { x: number; y: number },
|
||||
image: HTMLImageElement,
|
||||
widthScale: number,
|
||||
heightScale: number,
|
||||
yOffset: number = 0
|
||||
) {
|
||||
// Calculate distance between points
|
||||
const distance = Math.sqrt(
|
||||
Math.pow(rightPoint.x - leftPoint.x, 2) +
|
||||
Math.pow(rightPoint.y - leftPoint.y, 2)
|
||||
)
|
||||
|
||||
// Scale image based on distance
|
||||
const width = distance * PROCESSING_WIDTH * widthScale
|
||||
const height = width * heightScale
|
||||
|
||||
// Calculate center position between points
|
||||
const centerX = (leftPoint.x + rightPoint.x) / 2
|
||||
const centerY = (leftPoint.y + rightPoint.y) / 2 + yOffset
|
||||
|
||||
// Draw image
|
||||
this.outputCanvasCtx!.save()
|
||||
this.outputCanvasCtx!.translate(
|
||||
centerX * PROCESSING_WIDTH,
|
||||
centerY * PROCESSING_HEIGHT
|
||||
)
|
||||
|
||||
// Calculate rotation angle based on point positions
|
||||
const angle = Math.atan2(
|
||||
rightPoint.y - leftPoint.y,
|
||||
rightPoint.x - leftPoint.x
|
||||
)
|
||||
this.outputCanvasCtx!.rotate(angle)
|
||||
|
||||
// Draw image centered at the midpoint between points
|
||||
this.outputCanvasCtx!.drawImage(
|
||||
image,
|
||||
-width / 2,
|
||||
-height / 2,
|
||||
width,
|
||||
height
|
||||
)
|
||||
|
||||
this.outputCanvasCtx!.restore()
|
||||
}
|
||||
|
||||
async drawFaceLandmarks() {
|
||||
// Draw the original video frame at the canvas size
|
||||
this.outputCanvasCtx!.drawImage(
|
||||
this.videoElement!,
|
||||
0,
|
||||
0,
|
||||
this.videoElement!.videoWidth,
|
||||
this.videoElement!.videoHeight,
|
||||
0,
|
||||
0,
|
||||
PROCESSING_WIDTH,
|
||||
PROCESSING_HEIGHT
|
||||
)
|
||||
|
||||
if (!this.faceLandmarkerResult?.faceLandmarks) {
|
||||
return
|
||||
}
|
||||
|
||||
// Draw face landmarks (optional, for debugging)
|
||||
this.outputCanvasCtx!.strokeStyle = '#00FF00'
|
||||
this.outputCanvasCtx!.lineWidth = 2
|
||||
|
||||
for (const face of this.faceLandmarkerResult.faceLandmarks) {
|
||||
// Find eye landmarks
|
||||
const leftEye = face[468]
|
||||
const rightEye = face[473]
|
||||
|
||||
// Find mouth landmarks for mustache
|
||||
const leftMoustache = face[92]
|
||||
const rightMoustache = face[322]
|
||||
|
||||
// Find forehead landmarks for beret
|
||||
const leftForehead = face[103]
|
||||
const rightForehead = face[332]
|
||||
|
||||
if (leftEye && rightEye && this.options.showGlasses) {
|
||||
this.drawEffect(leftEye, rightEye, this.glassesImage!, 2.5, 0.7)
|
||||
}
|
||||
|
||||
if (leftMoustache && rightMoustache && this.options.showFrench) {
|
||||
this.drawEffect(
|
||||
leftMoustache,
|
||||
rightMoustache,
|
||||
this.mustacheImage!,
|
||||
1.5,
|
||||
0.5
|
||||
)
|
||||
}
|
||||
|
||||
if (leftForehead && rightForehead && this.options.showFrench) {
|
||||
this.drawEffect(
|
||||
leftForehead,
|
||||
rightForehead,
|
||||
this.beretImage!,
|
||||
2.1,
|
||||
0.7,
|
||||
-0.1
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async process() {
|
||||
await this.sizeSource()
|
||||
await this.detectFaces()
|
||||
await this.drawFaceLandmarks()
|
||||
|
||||
this.timerWorker!.postMessage({
|
||||
id: SET_TIMEOUT,
|
||||
timeMs: 1000 / 30,
|
||||
})
|
||||
}
|
||||
|
||||
_createMainCanvas() {
|
||||
this.outputCanvas = document.querySelector(
|
||||
`#${FACE_LANDMARKS_CANVAS_ID}`
|
||||
) as HTMLCanvasElement
|
||||
if (!this.outputCanvas) {
|
||||
this.outputCanvas = this._createCanvas(
|
||||
FACE_LANDMARKS_CANVAS_ID,
|
||||
PROCESSING_WIDTH,
|
||||
PROCESSING_HEIGHT
|
||||
)
|
||||
}
|
||||
this.outputCanvasCtx = this.outputCanvas.getContext('2d')!
|
||||
}
|
||||
|
||||
_createCanvas(id: string, width: number, height: number) {
|
||||
const element = document.createElement('canvas')
|
||||
element.setAttribute('id', id)
|
||||
element.setAttribute('width', '' + width)
|
||||
element.setAttribute('height', '' + height)
|
||||
return element
|
||||
}
|
||||
|
||||
update(opts: BackgroundOptions): void {
|
||||
this.options = opts
|
||||
}
|
||||
|
||||
async restart(opts: ProcessorOptions<Track.Kind>) {
|
||||
await this.destroy()
|
||||
return this.init(opts)
|
||||
}
|
||||
|
||||
async destroy() {
|
||||
this.timerWorker?.postMessage({
|
||||
id: CLEAR_TIMEOUT,
|
||||
})
|
||||
|
||||
this.timerWorker?.terminate()
|
||||
this.faceLandmarker?.close()
|
||||
}
|
||||
|
||||
clone() {
|
||||
return new FaceLandmarksProcessor(this.options)
|
||||
}
|
||||
|
||||
serialize() {
|
||||
return {
|
||||
type: this.type,
|
||||
options: this.options,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,10 +3,13 @@ import { Track, TrackProcessor } from 'livekit-client'
|
||||
import { BackgroundBlurTrackProcessorJsWrapper } from './BackgroundBlurTrackProcessorJsWrapper'
|
||||
import { BackgroundCustomProcessor } from './BackgroundCustomProcessor'
|
||||
import { BackgroundVirtualTrackProcessorJsWrapper } from './BackgroundVirtualTrackProcessorJsWrapper'
|
||||
import { FaceLandmarksProcessor } from './FaceLandmarksProcessor'
|
||||
|
||||
export type BackgroundOptions = {
|
||||
blurRadius?: number
|
||||
imagePath?: string
|
||||
showGlasses?: boolean
|
||||
showFrench?: boolean
|
||||
}
|
||||
|
||||
export interface ProcessorSerialized {
|
||||
@@ -25,11 +28,16 @@ export interface BackgroundProcessorInterface
|
||||
export enum ProcessorType {
|
||||
BLUR = 'blur',
|
||||
VIRTUAL = 'virtual',
|
||||
FACE_LANDMARKS = 'faceLandmarks',
|
||||
}
|
||||
|
||||
export class BackgroundProcessorFactory {
|
||||
static isSupported() {
|
||||
return ProcessorWrapper.isSupported || BackgroundCustomProcessor.isSupported
|
||||
return (
|
||||
ProcessorWrapper.isSupported ||
|
||||
BackgroundCustomProcessor.isSupported ||
|
||||
FaceLandmarksProcessor.isSupported
|
||||
)
|
||||
}
|
||||
|
||||
static getProcessor(
|
||||
@@ -50,6 +58,10 @@ export class BackgroundProcessorFactory {
|
||||
if (BackgroundCustomProcessor.isSupported) {
|
||||
return new BackgroundCustomProcessor(opts)
|
||||
}
|
||||
} else if (type === ProcessorType.FACE_LANDMARKS) {
|
||||
if (FaceLandmarksProcessor.isSupported) {
|
||||
return new FaceLandmarksProcessor(opts)
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
@@ -2,14 +2,14 @@ import { RiMegaphoneLine } from '@remixicon/react'
|
||||
import { MenuItem } from 'react-aria-components'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { menuRecipe } from '@/primitives/menuRecipe'
|
||||
import { GRIST_FORM } from '@/utils/constants'
|
||||
import { GRIST_FEEDBACKS_FORM } from '@/utils/constants'
|
||||
|
||||
export const FeedbackMenuItem = () => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'options.items' })
|
||||
|
||||
return (
|
||||
<MenuItem
|
||||
href={GRIST_FORM}
|
||||
href={GRIST_FEEDBACKS_FORM}
|
||||
target="_blank"
|
||||
className={menuRecipe({ icon: true, variant: 'dark' }).item}
|
||||
>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { FullScreenMenuItem } from './FullScreenMenuItem'
|
||||
import { SettingsMenuItem } from './SettingsMenuItem'
|
||||
import { FeedbackMenuItem } from './FeedbackMenuItem'
|
||||
import { EffectsMenuItem } from './EffectsMenuItem'
|
||||
import { SupportMenuItem } from './SupportMenuItem'
|
||||
|
||||
// @todo try refactoring it to use MenuList component
|
||||
export const OptionsMenuItems = () => {
|
||||
@@ -20,6 +21,7 @@ export const OptionsMenuItems = () => {
|
||||
</MenuSection>
|
||||
<Separator />
|
||||
<MenuSection>
|
||||
<SupportMenuItem />
|
||||
<FeedbackMenuItem />
|
||||
<SettingsMenuItem />
|
||||
</MenuSection>
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { RiQuestionLine } from '@remixicon/react'
|
||||
import { MenuItem } from 'react-aria-components'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { menuRecipe } from '@/primitives/menuRecipe'
|
||||
import { Crisp } from 'crisp-sdk-web'
|
||||
import { useIsSupportEnabled } from '@/features/support/hooks/useSupport'
|
||||
|
||||
export const SupportMenuItem = () => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'options.items' })
|
||||
const isSupportEnabled = useIsSupportEnabled()
|
||||
|
||||
if (!isSupportEnabled || !Crisp) {
|
||||
return
|
||||
}
|
||||
|
||||
return (
|
||||
<MenuItem
|
||||
className={menuRecipe({ icon: true, variant: 'dark' }).item}
|
||||
onAction={() => {
|
||||
Crisp?.chat.open()
|
||||
}}
|
||||
>
|
||||
<RiQuestionLine size={20} />
|
||||
{t('support')}
|
||||
</MenuItem>
|
||||
)
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
import { ToggleButton } from '@/primitives'
|
||||
import { RiQuestionLine } from '@remixicon/react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Crisp } from 'crisp-sdk-web'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { ToggleButtonProps } from '@/primitives/ToggleButton'
|
||||
import { useIsSupportEnabled } from '@/features/support/hooks/useSupport'
|
||||
|
||||
export const SupportToggle = ({ onPress, ...props }: ToggleButtonProps) => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'controls' })
|
||||
|
||||
const [isOpened, setIsOpened] = useState(() => {
|
||||
return window?.$crisp?.is?.('chat:opened') || false
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (!Crisp) {
|
||||
return
|
||||
}
|
||||
|
||||
Crisp.chat.onChatOpened(() => {
|
||||
setIsOpened(true)
|
||||
})
|
||||
Crisp.chat.onChatClosed(() => {
|
||||
setIsOpened(false)
|
||||
})
|
||||
return () => {
|
||||
Crisp.chat.offChatOpened()
|
||||
Crisp.chat.offChatClosed()
|
||||
}
|
||||
}, [])
|
||||
|
||||
const isSupportEnabled = useIsSupportEnabled()
|
||||
|
||||
if (!isSupportEnabled) {
|
||||
return
|
||||
}
|
||||
|
||||
return (
|
||||
<ToggleButton
|
||||
square
|
||||
variant="primaryTextDark"
|
||||
tooltip={t('support')}
|
||||
aria-label={t('support')}
|
||||
isSelected={isOpened}
|
||||
onPress={(e) => {
|
||||
if (isOpened) {
|
||||
Crisp.chat.close()
|
||||
} else {
|
||||
Crisp.chat.open()
|
||||
}
|
||||
onPress?.(e)
|
||||
}}
|
||||
data-attr="controls-support"
|
||||
{...props}
|
||||
>
|
||||
<RiQuestionLine />
|
||||
</ToggleButton>
|
||||
)
|
||||
}
|
||||
@@ -1,24 +1,19 @@
|
||||
import { ToggleButton } from '@/primitives'
|
||||
import { RiBardLine } from '@remixicon/react'
|
||||
import { RiShapesLine } from '@remixicon/react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useSidePanel } from '../../hooks/useSidePanel'
|
||||
import { useHasTranscriptAccess } from '../../hooks/useHasTranscriptAccess'
|
||||
import { css } from '@/styled-system/css'
|
||||
import { ToggleButtonProps } from '@/primitives/ToggleButton'
|
||||
|
||||
export const TranscriptToggle = ({
|
||||
export const ToolsToggle = ({
|
||||
variant = 'primaryTextDark',
|
||||
onPress,
|
||||
...props
|
||||
}: ToggleButtonProps) => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'controls.transcript' })
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'controls.tools' })
|
||||
|
||||
const { isTranscriptOpen, toggleTranscript } = useSidePanel()
|
||||
const tooltipLabel = isTranscriptOpen ? 'open' : 'closed'
|
||||
|
||||
const hasTranscriptAccess = useHasTranscriptAccess()
|
||||
|
||||
if (!hasTranscriptAccess) return
|
||||
const { isToolsOpen, toggleTools } = useSidePanel()
|
||||
const tooltipLabel = isToolsOpen ? 'open' : 'closed'
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -32,15 +27,15 @@ export const TranscriptToggle = ({
|
||||
variant={variant}
|
||||
aria-label={t(tooltipLabel)}
|
||||
tooltip={t(tooltipLabel)}
|
||||
isSelected={isTranscriptOpen}
|
||||
isSelected={isToolsOpen}
|
||||
onPress={(e) => {
|
||||
toggleTranscript()
|
||||
toggleTools()
|
||||
onPress?.(e)
|
||||
}}
|
||||
{...props}
|
||||
data-attr="toggle-transcript"
|
||||
data-attr="toggle-tools"
|
||||
>
|
||||
<RiBardLine />
|
||||
<RiShapesLine />
|
||||
</ToggleButton>
|
||||
</div>
|
||||
)
|
||||
@@ -13,6 +13,7 @@ export const Effects = () => {
|
||||
<div
|
||||
className={css({
|
||||
padding: '0 1.5rem',
|
||||
overflowY: 'scroll',
|
||||
})}
|
||||
>
|
||||
<EffectsConfiguration
|
||||
|
||||
@@ -5,17 +5,22 @@ import {
|
||||
BackgroundProcessorFactory,
|
||||
BackgroundProcessorInterface,
|
||||
ProcessorType,
|
||||
BackgroundOptions,
|
||||
} from '../blur'
|
||||
import { css } from '@/styled-system/css'
|
||||
import { Text, P, ToggleButton, H } from '@/primitives'
|
||||
import { styled } from '@/styled-system/jsx'
|
||||
import { BackgroundOptions } from '@livekit/track-processors'
|
||||
import { BlurOn } from '@/components/icons/BlurOn'
|
||||
import { BlurOnStrong } from '@/components/icons/BlurOnStrong'
|
||||
import { useTrackToggle } from '@livekit/components-react'
|
||||
import { Loader } from '@/primitives/Loader'
|
||||
import { useSyncAfterDelay } from '@/hooks/useSyncAfterDelay'
|
||||
import { RiProhibited2Line } from '@remixicon/react'
|
||||
import {
|
||||
RiProhibited2Line,
|
||||
RiGlassesLine,
|
||||
RiGoblet2Fill,
|
||||
} from '@remixicon/react'
|
||||
import { useHasFaceLandmarksAccess } from '../../hooks/useHasFaceLandmarksAccess'
|
||||
|
||||
enum BlurRadius {
|
||||
NONE = 0,
|
||||
@@ -50,6 +55,7 @@ export const EffectsConfiguration = ({
|
||||
const { toggle, enabled } = useTrackToggle({ source: Track.Source.Camera })
|
||||
const [processorPending, setProcessorPending] = useState(false)
|
||||
const processorPendingReveal = useSyncAfterDelay(processorPending)
|
||||
const hasFaceLandmarksAccess = useHasFaceLandmarksAccess()
|
||||
|
||||
useEffect(() => {
|
||||
const videoElement = videoRef.current
|
||||
@@ -139,9 +145,42 @@ export const EffectsConfiguration = ({
|
||||
}
|
||||
|
||||
const tooltipLabel = (type: ProcessorType, options: BackgroundOptions) => {
|
||||
if (type === ProcessorType.FACE_LANDMARKS) {
|
||||
const effect = options.showGlasses ? 'glasses' : 'french'
|
||||
return t(
|
||||
`faceLandmarks.${effect}.${isSelected(type, options) ? 'clear' : 'apply'}`
|
||||
)
|
||||
}
|
||||
return t(`${type}.${isSelected(type, options) ? 'clear' : 'apply'}`)
|
||||
}
|
||||
|
||||
const getFaceLandmarksOptions = () => {
|
||||
const processor = getProcessor()
|
||||
if (processor?.serialize().type === ProcessorType.FACE_LANDMARKS) {
|
||||
return processor.serialize().options as {
|
||||
showGlasses?: boolean
|
||||
showFrench?: boolean
|
||||
}
|
||||
}
|
||||
return { showGlasses: false, showFrench: false }
|
||||
}
|
||||
|
||||
const toggleFaceLandmarkEffect = async (effect: 'glasses' | 'french') => {
|
||||
const currentOptions = getFaceLandmarksOptions()
|
||||
const newOptions = {
|
||||
...currentOptions,
|
||||
[effect === 'glasses' ? 'showGlasses' : 'showFrench']:
|
||||
!currentOptions[effect === 'glasses' ? 'showGlasses' : 'showFrench'],
|
||||
}
|
||||
|
||||
if (!newOptions.showGlasses && !newOptions.showFrench) {
|
||||
// If both effects are off stop the processor
|
||||
await clearEffect()
|
||||
} else {
|
||||
await toggleEffect(ProcessorType.FACE_LANDMARKS, newOptions)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={css(
|
||||
@@ -302,6 +341,68 @@ export const EffectsConfiguration = ({
|
||||
</ToggleButton>
|
||||
</div>
|
||||
</div>
|
||||
{hasFaceLandmarksAccess && (
|
||||
<div
|
||||
className={css({
|
||||
marginTop: '1.5rem',
|
||||
})}
|
||||
>
|
||||
<H
|
||||
lvl={3}
|
||||
style={{
|
||||
marginBottom: '1rem',
|
||||
}}
|
||||
variant="bodyXsBold"
|
||||
>
|
||||
{t('faceLandmarks.title')}
|
||||
</H>
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
gap: '1.25rem',
|
||||
})}
|
||||
>
|
||||
<ToggleButton
|
||||
variant="bigSquare"
|
||||
aria-label={tooltipLabel(ProcessorType.FACE_LANDMARKS, {
|
||||
showGlasses: true,
|
||||
showFrench: false,
|
||||
})}
|
||||
tooltip={tooltipLabel(ProcessorType.FACE_LANDMARKS, {
|
||||
showGlasses: true,
|
||||
showFrench: false,
|
||||
})}
|
||||
isDisabled={processorPendingReveal}
|
||||
onChange={async () =>
|
||||
await toggleFaceLandmarkEffect('glasses')
|
||||
}
|
||||
isSelected={getFaceLandmarksOptions().showGlasses}
|
||||
data-attr="toggle-glasses"
|
||||
>
|
||||
<RiGlassesLine />
|
||||
</ToggleButton>
|
||||
<ToggleButton
|
||||
variant="bigSquare"
|
||||
aria-label={tooltipLabel(ProcessorType.FACE_LANDMARKS, {
|
||||
showGlasses: false,
|
||||
showFrench: true,
|
||||
})}
|
||||
tooltip={tooltipLabel(ProcessorType.FACE_LANDMARKS, {
|
||||
showGlasses: false,
|
||||
showFrench: true,
|
||||
})}
|
||||
isDisabled={processorPendingReveal}
|
||||
onChange={async () =>
|
||||
await toggleFaceLandmarkEffect('french')
|
||||
}
|
||||
isSelected={getFaceLandmarksOptions().showFrench}
|
||||
data-attr="toggle-french"
|
||||
>
|
||||
<RiGoblet2Fill />
|
||||
</ToggleButton>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={css({
|
||||
marginTop: '1.5rem',
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import { useFeatureFlagEnabled } from 'posthog-js/react'
|
||||
import { useIsAnalyticsEnabled } from '@/features/analytics/hooks/useIsAnalyticsEnabled'
|
||||
|
||||
export const useHasFaceLandmarksAccess = () => {
|
||||
const featureEnabled = useFeatureFlagEnabled('face-landmarks')
|
||||
const isAnalyticsEnabled = useIsAnalyticsEnabled()
|
||||
|
||||
return featureEnabled || !isAnalyticsEnabled
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { useFeatureFlagEnabled } from 'posthog-js/react'
|
||||
import { useIsAnalyticsEnabled } from '@/features/analytics/hooks/useIsAnalyticsEnabled'
|
||||
import { RecordingMode } from '@/features/rooms/api/startRecording'
|
||||
import { useIsRecordingEnabled } from './useIsRecordingEnabled'
|
||||
import { useIsAdminOrOwner } from './useIsAdminOrOwner'
|
||||
|
||||
export const useHasScreenRecordingAccess = () => {
|
||||
const featureEnabled = useFeatureFlagEnabled('screen-recording')
|
||||
const isAnalyticsEnabled = useIsAnalyticsEnabled()
|
||||
const isScreenRecordingEnabled = useIsRecordingEnabled(
|
||||
RecordingMode.ScreenRecording
|
||||
)
|
||||
const isAdminOrOwner = useIsAdminOrOwner()
|
||||
|
||||
return (
|
||||
(featureEnabled || !isAnalyticsEnabled) &&
|
||||
isAdminOrOwner &&
|
||||
isScreenRecordingEnabled
|
||||
)
|
||||
}
|
||||
@@ -1,12 +1,13 @@
|
||||
import { useFeatureFlagEnabled } from 'posthog-js/react'
|
||||
import { useIsAnalyticsEnabled } from '@/features/analytics/hooks/useIsAnalyticsEnabled'
|
||||
import { useIsTranscriptEnabled } from './useIsTranscriptEnabled'
|
||||
import { RecordingMode } from '@/features/rooms/api/startRecording'
|
||||
import { useIsAdminOrOwner } from './useIsAdminOrOwner'
|
||||
import { useIsRecordingEnabled } from './useIsRecordingEnabled'
|
||||
|
||||
export const useHasTranscriptAccess = () => {
|
||||
const featureEnabled = useFeatureFlagEnabled('transcription-summary')
|
||||
const isAnalyticsEnabled = useIsAnalyticsEnabled()
|
||||
const isTranscriptEnabled = useIsTranscriptEnabled()
|
||||
const isTranscriptEnabled = useIsRecordingEnabled(RecordingMode.Transcript)
|
||||
const isAdminOrOwner = useIsAdminOrOwner()
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { RecordingMode } from '@/features/rooms/api/startRecording'
|
||||
import { useConfig } from '@/api/useConfig'
|
||||
|
||||
export const useIsTranscriptEnabled = () => {
|
||||
export const useIsRecordingEnabled = (mode: RecordingMode) => {
|
||||
const { data } = useConfig()
|
||||
|
||||
return (
|
||||
data?.recording?.is_enabled &&
|
||||
data?.recording?.available_modes?.includes(RecordingMode.Transcript)
|
||||
data?.recording?.available_modes?.includes(mode)
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { useSnapshot } from 'valtio'
|
||||
import { RecordingStatus, recordingStore } from '@/stores/recording'
|
||||
|
||||
export const useIsScreenRecordingStarted = () => {
|
||||
const recordingSnap = useSnapshot(recordingStore)
|
||||
return recordingSnap.status == RecordingStatus.SCREEN_RECORDING_STARTED
|
||||
}
|
||||
|
||||
export const useIsTranscriptStarted = () => {
|
||||
const recordingSnap = useSnapshot(recordingStore)
|
||||
return recordingSnap.status == RecordingStatus.TRANSCRIPT_STARTED
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { useSnapshot } from 'valtio'
|
||||
import { RecordingStatus, recordingStore } from '@/stores/recording'
|
||||
|
||||
export const useIsRecordingTransitioning = () => {
|
||||
const recordingSnap = useSnapshot(recordingStore)
|
||||
|
||||
const transitionalStates = [
|
||||
RecordingStatus.TRANSCRIPT_STARTING,
|
||||
RecordingStatus.TRANSCRIPT_STOPPING,
|
||||
RecordingStatus.SCREEN_RECORDING_STARTING,
|
||||
RecordingStatus.SCREEN_RECORDING_STOPPING,
|
||||
]
|
||||
|
||||
return transitionalStates.includes(recordingSnap.status)
|
||||
}
|
||||
@@ -5,53 +5,83 @@ export enum PanelId {
|
||||
PARTICIPANTS = 'participants',
|
||||
EFFECTS = 'effects',
|
||||
CHAT = 'chat',
|
||||
TRANSCRIPT = 'transcript',
|
||||
TOOLS = 'tools',
|
||||
ADMIN = 'admin',
|
||||
}
|
||||
|
||||
export enum SubPanelId {
|
||||
TRANSCRIPT = 'transcript',
|
||||
SCREEN_RECORDING = 'screenRecording',
|
||||
}
|
||||
|
||||
export const useSidePanel = () => {
|
||||
const layoutSnap = useSnapshot(layoutStore)
|
||||
const activePanelId = layoutSnap.activePanelId
|
||||
const activeSubPanelId = layoutSnap.activeSubPanelId
|
||||
|
||||
const isParticipantsOpen = activePanelId == PanelId.PARTICIPANTS
|
||||
const isEffectsOpen = activePanelId == PanelId.EFFECTS
|
||||
const isChatOpen = activePanelId == PanelId.CHAT
|
||||
const isTranscriptOpen = activePanelId == PanelId.TRANSCRIPT
|
||||
const isToolsOpen = activePanelId == PanelId.TOOLS
|
||||
const isAdminOpen = activePanelId == PanelId.ADMIN
|
||||
const isTranscriptOpen = activeSubPanelId == SubPanelId.TRANSCRIPT
|
||||
const isScreenRecordingOpen = activeSubPanelId == SubPanelId.SCREEN_RECORDING
|
||||
const isSidePanelOpen = !!activePanelId
|
||||
const isSubPanelOpen = !!activeSubPanelId
|
||||
|
||||
const toggleAdmin = () => {
|
||||
layoutStore.activePanelId = isAdminOpen ? null : PanelId.ADMIN
|
||||
if (layoutSnap.activeSubPanelId) layoutStore.activeSubPanelId = null
|
||||
}
|
||||
|
||||
const toggleParticipants = () => {
|
||||
layoutStore.activePanelId = isParticipantsOpen ? null : PanelId.PARTICIPANTS
|
||||
if (layoutSnap.activeSubPanelId) layoutStore.activeSubPanelId = null
|
||||
}
|
||||
|
||||
const toggleChat = () => {
|
||||
layoutStore.activePanelId = isChatOpen ? null : PanelId.CHAT
|
||||
if (layoutSnap.activeSubPanelId) layoutStore.activeSubPanelId = null
|
||||
}
|
||||
|
||||
const toggleEffects = () => {
|
||||
layoutStore.activePanelId = isEffectsOpen ? null : PanelId.EFFECTS
|
||||
if (layoutSnap.activeSubPanelId) layoutStore.activeSubPanelId = null
|
||||
}
|
||||
|
||||
const toggleTranscript = () => {
|
||||
layoutStore.activePanelId = isTranscriptOpen ? null : PanelId.TRANSCRIPT
|
||||
const toggleTools = () => {
|
||||
layoutStore.activePanelId = isToolsOpen ? null : PanelId.TOOLS
|
||||
if (layoutSnap.activeSubPanelId) layoutStore.activeSubPanelId = null
|
||||
}
|
||||
|
||||
const openTranscript = () => {
|
||||
layoutStore.activeSubPanelId = SubPanelId.TRANSCRIPT
|
||||
layoutStore.activePanelId = PanelId.TOOLS
|
||||
}
|
||||
|
||||
const openScreenRecording = () => {
|
||||
layoutStore.activeSubPanelId = SubPanelId.SCREEN_RECORDING
|
||||
layoutStore.activePanelId = PanelId.TOOLS
|
||||
}
|
||||
|
||||
return {
|
||||
activePanelId,
|
||||
activeSubPanelId,
|
||||
toggleParticipants,
|
||||
toggleChat,
|
||||
toggleEffects,
|
||||
toggleTranscript,
|
||||
toggleTools,
|
||||
toggleAdmin,
|
||||
openTranscript,
|
||||
openScreenRecording,
|
||||
isSubPanelOpen,
|
||||
isChatOpen,
|
||||
isParticipantsOpen,
|
||||
isEffectsOpen,
|
||||
isSidePanelOpen,
|
||||
isTranscriptOpen,
|
||||
isToolsOpen,
|
||||
isAdminOpen,
|
||||
isTranscriptOpen,
|
||||
isScreenRecordingOpen,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,15 +14,15 @@ import {
|
||||
RiMore2Line,
|
||||
RiSettings3Line,
|
||||
} from '@remixicon/react'
|
||||
import { GRIST_FEEDBACKS_FORM } from '@/utils/constants'
|
||||
import { ScreenShareToggle } from '../../components/controls/ScreenShareToggle'
|
||||
import { ChatToggle } from '../../components/controls/ChatToggle'
|
||||
import { ParticipantsToggle } from '../../components/controls/Participants/ParticipantsToggle'
|
||||
import { SupportToggle } from '../../components/controls/SupportToggle'
|
||||
import { useSidePanel } from '../../hooks/useSidePanel'
|
||||
import { LinkButton } from '@/primitives'
|
||||
import { useSettingsDialog } from '../../components/controls/SettingsDialogContext'
|
||||
import { ResponsiveMenu } from './ResponsiveMenu'
|
||||
import { TranscriptToggle } from '../../components/controls/TranscriptToggle'
|
||||
import { ToolsToggle } from '../../components/controls/ToolsToggle'
|
||||
import { CameraSwitchButton } from '../../components/controls/CameraSwitchButton'
|
||||
|
||||
export function MobileControlBar({
|
||||
@@ -134,11 +134,7 @@ export function MobileControlBar({
|
||||
description={true}
|
||||
onPress={() => setIsMenuOpened(false)}
|
||||
/>
|
||||
<TranscriptToggle
|
||||
description={true}
|
||||
onPress={() => setIsMenuOpened(false)}
|
||||
/>
|
||||
<SupportToggle
|
||||
<ToolsToggle
|
||||
description={true}
|
||||
onPress={() => setIsMenuOpened(false)}
|
||||
/>
|
||||
@@ -155,7 +151,7 @@ export function MobileControlBar({
|
||||
<RiAccountBoxLine size={20} />
|
||||
</Button>
|
||||
<LinkButton
|
||||
href="https://grist.incubateur.net/o/docs/forms/1YrfNP1QSSy8p2gCxMFnSf/4"
|
||||
href={GRIST_FEEDBACKS_FORM}
|
||||
variant="primaryTextDark"
|
||||
tooltip={t('options.items.feedback')}
|
||||
aria-label={t('options.items.feedback')}
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { css } from '@/styled-system/css'
|
||||
import { ChatToggle } from '../../components/controls/ChatToggle'
|
||||
import { ParticipantsToggle } from '../../components/controls/Participants/ParticipantsToggle'
|
||||
import { SupportToggle } from '../../components/controls/SupportToggle'
|
||||
import { TranscriptToggle } from '../../components/controls/TranscriptToggle'
|
||||
import { ToolsToggle } from '../../components/controls/ToolsToggle'
|
||||
import { AdminToggle } from '../../components/AdminToggle'
|
||||
import { useSize } from '../../hooks/useResizeObserver'
|
||||
import { useState, RefObject } from 'react'
|
||||
@@ -14,13 +13,15 @@ import { useTranslation } from 'react-i18next'
|
||||
|
||||
const CONTROL_BAR_BREAKPOINT = 1100
|
||||
|
||||
const NavigationControls = ({ onPress }: Partial<ToggleButtonProps>) => (
|
||||
const NavigationControls = ({
|
||||
onPress,
|
||||
tooltipType = 'instant',
|
||||
}: Partial<ToggleButtonProps>) => (
|
||||
<>
|
||||
<ChatToggle onPress={onPress} />
|
||||
<ParticipantsToggle onPress={onPress} />
|
||||
<TranscriptToggle onPress={onPress} />
|
||||
<SupportToggle onPress={onPress} />
|
||||
<AdminToggle onPress={onPress} />
|
||||
<ChatToggle onPress={onPress} tooltipType={tooltipType} />
|
||||
<ParticipantsToggle onPress={onPress} tooltipType={tooltipType} />
|
||||
<ToolsToggle onPress={onPress} tooltipType={tooltipType} />
|
||||
<AdminToggle onPress={onPress} tooltipType={tooltipType} />
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -55,7 +56,7 @@ export const LateralMenu = () => {
|
||||
gap: '0.5rem',
|
||||
})}
|
||||
>
|
||||
<NavigationControls onPress={handleClose} />
|
||||
<NavigationControls onPress={handleClose} tooltipType="delayed" />
|
||||
</Dialog>
|
||||
</Popover>
|
||||
</DialogTrigger>
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
} from '@livekit/components-core'
|
||||
import { RoomEvent, Track } from 'livekit-client'
|
||||
import * as React from 'react'
|
||||
|
||||
import { useState } from 'react'
|
||||
import {
|
||||
CarouselLayout,
|
||||
ConnectionStateToast,
|
||||
@@ -29,6 +29,7 @@ import { ParticipantTile } from '../components/ParticipantTile'
|
||||
import { SidePanel } from '../components/SidePanel'
|
||||
import { useSidePanel } from '../hooks/useSidePanel'
|
||||
import { RecordingStateToast } from '../components/RecordingStateToast'
|
||||
import { ScreenShareErrorModal } from '../components/ScreenShareErrorModal'
|
||||
|
||||
const LayoutWrapper = styled(
|
||||
'div',
|
||||
@@ -149,6 +150,8 @@ export function VideoConference({ ...props }: VideoConferenceProps) {
|
||||
|
||||
const { isSidePanelOpen } = useSidePanel()
|
||||
|
||||
const [isShareErrorVisible, setIsShareErrorVisible] = useState(false)
|
||||
|
||||
return (
|
||||
<div
|
||||
className="lk-video-conference"
|
||||
@@ -162,6 +165,10 @@ export function VideoConference({ ...props }: VideoConferenceProps) {
|
||||
value={layoutContext}
|
||||
// onPinChange={handleFocusStateChange}
|
||||
>
|
||||
<ScreenShareErrorModal
|
||||
isOpen={isShareErrorVisible}
|
||||
onClose={() => setIsShareErrorVisible(false)}
|
||||
/>
|
||||
<div
|
||||
// todo - extract these magic values into constant
|
||||
style={{
|
||||
@@ -207,7 +214,18 @@ export function VideoConference({ ...props }: VideoConferenceProps) {
|
||||
</LayoutWrapper>
|
||||
<MainNotificationToast />
|
||||
</div>
|
||||
<ControlBar />
|
||||
<ControlBar
|
||||
onDeviceError={(e) => {
|
||||
console.error(e)
|
||||
if (
|
||||
e.source == Track.Source.ScreenShare &&
|
||||
e.error.toString() ==
|
||||
'NotAllowedError: Permission denied by system'
|
||||
) {
|
||||
setIsShareErrorVisible(true)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<SidePanel />
|
||||
</LayoutContextProvider>
|
||||
)}
|
||||
|
||||
@@ -1,84 +0,0 @@
|
||||
import { authUrl, useUser } from '../auth'
|
||||
|
||||
export enum ClientMessageType {
|
||||
ROOM_CREATED = 'ROOM_CREATED',
|
||||
}
|
||||
|
||||
export class SdkReverseClient {
|
||||
/**
|
||||
* IDEA: Use API Key. Must be based on some sort of credentials? No needs for now as there are no security
|
||||
* plausible at the moment.
|
||||
*/
|
||||
static getAllowTargetOrigin() {
|
||||
return '*'
|
||||
}
|
||||
|
||||
static post(type: ClientMessageType, data: unknown = {}) {
|
||||
window.parent.postMessage(
|
||||
{
|
||||
type,
|
||||
data,
|
||||
},
|
||||
SdkReverseClient.getAllowTargetOrigin()
|
||||
)
|
||||
}
|
||||
|
||||
static broadcastAuthentication() {
|
||||
const bc = new BroadcastChannel('APP_CHANNEL')
|
||||
bc.postMessage({ type: 'AUTHENTICATED' })
|
||||
|
||||
/**
|
||||
* This means the parent window has authenticated has successfully refetched user, then we can close the popup.
|
||||
*/
|
||||
bc.onmessage = (event) => {
|
||||
if (event.data.type === 'AUTHENTICATED_ACK') {
|
||||
window.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static waitForAuthenticationAck() {
|
||||
return new Promise<void>((resolve) => {
|
||||
const bc = new BroadcastChannel('APP_CHANNEL')
|
||||
bc.onmessage = async (event) => {
|
||||
if (event.data.type === 'AUTHENTICATED') {
|
||||
resolve()
|
||||
bc.postMessage({ type: 'AUTHENTICATED_ACK' })
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a function to be awaited in order to make sure the user is logged in.
|
||||
* If not logged-in it opens a popup with the connection flow, the promise returned is resolved
|
||||
* once logged-in.
|
||||
*
|
||||
* To be used in SDK scope.
|
||||
*/
|
||||
export function useEnsureAuth() {
|
||||
const { isLoggedIn, ...other } = useUser({
|
||||
fetchUserOptions: { attemptSilent: false },
|
||||
})
|
||||
|
||||
const startSSO = () => {
|
||||
return new Promise<void>((resolve) => {
|
||||
SdkReverseClient.waitForAuthenticationAck().then(async () => {
|
||||
await other.refetch()
|
||||
resolve()
|
||||
})
|
||||
const params = `scrollbars=no,resizable=no,status=no,location=no,toolbar=no,menubar=no,
|
||||
width=400,height=900,left=100,top=100`
|
||||
window.open(new URL('authenticate/', authUrl()).href, '', params)
|
||||
})
|
||||
}
|
||||
|
||||
const ensureAuth = async () => {
|
||||
if (!isLoggedIn) {
|
||||
await startSSO()
|
||||
}
|
||||
}
|
||||
|
||||
return { ensureAuth }
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { fetchApi } from '@/api/fetchApi'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { keys } from '@/api/queryKeys'
|
||||
import { CallbackCreationRoomData } from '../utils/types'
|
||||
|
||||
export type CallbackResponse = {
|
||||
status: string
|
||||
room: CallbackCreationRoomData
|
||||
}
|
||||
|
||||
export const fetchRoomGenerationState = async ({
|
||||
callbackId,
|
||||
}: {
|
||||
callbackId: string
|
||||
}) => {
|
||||
return fetchApi<CallbackResponse>(`/rooms/creation-callback/`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
callback_id: callbackId,
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
export const useRoomCreationCallback = ({
|
||||
callbackId = '',
|
||||
}: {
|
||||
callbackId?: string
|
||||
}) => {
|
||||
return useQuery({
|
||||
queryKey: [keys.roomCreationCallback, callbackId],
|
||||
queryFn: () => fetchRoomGenerationState({ callbackId }),
|
||||
enabled: !!callbackId,
|
||||
refetchInterval: 1000,
|
||||
})
|
||||
}
|
||||
@@ -1,103 +0,0 @@
|
||||
import { Button } from '@/primitives/Button'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { usePersistentUserChoices } from '@livekit/components-react'
|
||||
import { useState } from 'react'
|
||||
import { getRouteUrl } from '@/navigation/getRouteUrl'
|
||||
import { css } from '@/styled-system/css'
|
||||
import { RiCheckLine, RiFileCopyLine } from '@remixicon/react'
|
||||
import { VisioIcon } from '@/assets/VisioIcon'
|
||||
import { generateRoomId, useCreateRoom } from '../../rooms'
|
||||
import {
|
||||
ClientMessageType,
|
||||
SdkReverseClient,
|
||||
useEnsureAuth,
|
||||
} from '../SdkReverseClient'
|
||||
|
||||
export const SdkCreateButton = () => {
|
||||
const { t } = useTranslation('sdk', { keyPrefix: 'createButton' })
|
||||
const [roomUrl, setRoomUrl] = useState<string>()
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const {
|
||||
userChoices: { username },
|
||||
} = usePersistentUserChoices()
|
||||
|
||||
const { mutateAsync: createRoom } = useCreateRoom()
|
||||
const { ensureAuth } = useEnsureAuth()
|
||||
|
||||
const submitCreateRoom = async () => {
|
||||
setIsLoading(true)
|
||||
const slug = generateRoomId()
|
||||
const data = await createRoom({ slug, username })
|
||||
const roomUrlTmp = getRouteUrl('room', data.slug)
|
||||
setRoomUrl(roomUrlTmp)
|
||||
setIsLoading(false)
|
||||
SdkReverseClient.post(ClientMessageType.ROOM_CREATED, {
|
||||
url: roomUrlTmp,
|
||||
})
|
||||
}
|
||||
|
||||
const submit = async () => {
|
||||
await ensureAuth()
|
||||
submitCreateRoom()
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={css({
|
||||
paddingTop: '3px',
|
||||
paddingLeft: '3px',
|
||||
})}
|
||||
>
|
||||
{roomUrl ? (
|
||||
<RoomUrl roomUrl={roomUrl} />
|
||||
) : (
|
||||
<Button
|
||||
variant="primaryDark"
|
||||
aria-label={t('label')}
|
||||
onPress={submit}
|
||||
data-attr="sdk-create"
|
||||
loading={isLoading}
|
||||
icon={<VisioIcon />}
|
||||
>
|
||||
{t('label')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const RoomUrl = ({ roomUrl }: { roomUrl: string }) => {
|
||||
const [isCopied, setIsCopied] = useState(false)
|
||||
|
||||
const copy = () => {
|
||||
navigator.clipboard.writeText(roomUrl!)
|
||||
setIsCopied(true)
|
||||
setTimeout(() => setIsCopied(false), 1000)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.5rem',
|
||||
})}
|
||||
>
|
||||
<span
|
||||
className={css({
|
||||
color: 'greyscale.600',
|
||||
})}
|
||||
>
|
||||
{roomUrl}
|
||||
</span>
|
||||
<Button
|
||||
variant={isCopied ? 'success' : 'quaternaryText'}
|
||||
data-attr="sdk-create-copy"
|
||||
onPress={copy}
|
||||
square
|
||||
>
|
||||
{isCopied ? <RiCheckLine /> : <RiFileCopyLine />}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
import { Button } from '@/primitives/Button'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { Link } from 'react-aria-components'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { HStack, VStack } from '@/styled-system/jsx'
|
||||
import { css } from '@/styled-system/css'
|
||||
import { RiCloseLine, RiFileCopyLine } from '@remixicon/react'
|
||||
import { Text } from '@/primitives'
|
||||
import { Spinner } from '@/primitives/Spinner'
|
||||
import { buttonRecipe } from '@/primitives/buttonRecipe'
|
||||
import { VisioIcon } from '@/assets/VisioIcon'
|
||||
import { getRouteUrl } from '@/navigation/getRouteUrl'
|
||||
import { useRoomCreationCallback } from '../api/useRoomCreationCallback'
|
||||
import { PopupManager } from '../utils/PopupManager'
|
||||
import { CallbackCreationRoomData } from '../utils/types'
|
||||
import { useSearchParams } from 'wouter'
|
||||
|
||||
const popupManager = new PopupManager()
|
||||
|
||||
export const CreateMeetingButton = () => {
|
||||
const { t } = useTranslation('sdk', { keyPrefix: 'createMeeting' })
|
||||
|
||||
const [searchParams] = useSearchParams()
|
||||
|
||||
const [callbackId, setCallbackId] = useState<string | undefined>(undefined)
|
||||
const [isPending, setIsPending] = useState(false)
|
||||
|
||||
const initialRoom = useMemo(() => {
|
||||
const roomSlug = searchParams.get('slug')
|
||||
if (!roomSlug) return undefined
|
||||
return {
|
||||
slug: roomSlug.trim(), // Trim whitespace for safety
|
||||
}
|
||||
}, [searchParams])
|
||||
|
||||
const [room, setRoom] = useState<CallbackCreationRoomData | undefined>(
|
||||
initialRoom
|
||||
)
|
||||
|
||||
const { data } = useRoomCreationCallback({ callbackId })
|
||||
|
||||
const roomUrl = useMemo(() => {
|
||||
if (room?.slug) return getRouteUrl('room', room.slug)
|
||||
}, [room])
|
||||
|
||||
useEffect(() => {
|
||||
if (!data?.room?.slug) return
|
||||
setRoom(data.room)
|
||||
setCallbackId(undefined)
|
||||
setIsPending(false)
|
||||
popupManager.sendRoomData({
|
||||
room: {
|
||||
url: getRouteUrl('room', data.room.slug),
|
||||
...data.room,
|
||||
},
|
||||
})
|
||||
}, [data])
|
||||
|
||||
useEffect(() => {
|
||||
popupManager.setupMessageListener(
|
||||
(id) => setCallbackId(id),
|
||||
(data) => {
|
||||
setRoom(data)
|
||||
setIsPending(false)
|
||||
}
|
||||
)
|
||||
|
||||
return () => popupManager.cleanup()
|
||||
}, [])
|
||||
|
||||
const resetState = () => {
|
||||
setRoom(undefined)
|
||||
setCallbackId(undefined)
|
||||
setIsPending(false)
|
||||
popupManager.clearState()
|
||||
}
|
||||
|
||||
if (isPending) {
|
||||
return (
|
||||
<div>
|
||||
<Spinner size={34} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="p-6"
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'start',
|
||||
alignItems: 'start',
|
||||
border: 'none',
|
||||
}}
|
||||
>
|
||||
{roomUrl && room?.slug ? (
|
||||
<VStack justify={'start'} alignItems={'start'} gap={0.25}>
|
||||
<HStack>
|
||||
<Link
|
||||
className={buttonRecipe({ size: 'sm' })}
|
||||
href={roomUrl}
|
||||
target="_blank"
|
||||
style={{
|
||||
textWrap: 'nowrap',
|
||||
}}
|
||||
>
|
||||
<VisioIcon />
|
||||
{t('joinButton')}
|
||||
</Link>
|
||||
<HStack gap={0}>
|
||||
<Button
|
||||
variant="quaternaryText"
|
||||
square
|
||||
icon={<RiFileCopyLine />}
|
||||
tooltip={t('copyLinkTooltip')}
|
||||
onPress={() => {
|
||||
navigator.clipboard.writeText(roomUrl)
|
||||
}}
|
||||
/>
|
||||
{searchParams.get('readOnly') === 'false' && (
|
||||
<Button
|
||||
variant="quaternaryText"
|
||||
square
|
||||
icon={<RiCloseLine />}
|
||||
onPress={resetState}
|
||||
aria-label={t('resetLabel')}
|
||||
/>
|
||||
)}
|
||||
</HStack>
|
||||
</HStack>
|
||||
<VStack justify={'start'} alignItems="start" gap={0.25}>
|
||||
<Text variant={'smNote'} margin={false} centered={false}>
|
||||
{roomUrl.replace('https://', '')}
|
||||
</Text>
|
||||
<Text variant={'smNote'} margin={false} centered={false}>
|
||||
{t('participantLimit')}
|
||||
</Text>
|
||||
</VStack>
|
||||
</VStack>
|
||||
) : (
|
||||
<div
|
||||
className={css({
|
||||
minHeight: '46px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'center',
|
||||
})}
|
||||
>
|
||||
{/*
|
||||
* Using popup for Visio to access session cookies (blocked in iframes).
|
||||
* If authenticated: Popup creates room and returns data directly.
|
||||
* If not: Popup sends callbackId, redirects to login, then backend
|
||||
* associates new room with callbackId after authentication.
|
||||
*/}
|
||||
<Button
|
||||
onPress={() => {
|
||||
setIsPending(true)
|
||||
popupManager.createPopupWindow(() => {
|
||||
setIsPending(false)
|
||||
})
|
||||
}}
|
||||
size="sm"
|
||||
>
|
||||
<VisioIcon />
|
||||
{t('createButton')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { useEffect, useMemo } from 'react'
|
||||
import { css } from '@/styled-system/css'
|
||||
import { generateRoomId, useCreateRoom } from '../../rooms'
|
||||
import { useUser } from '@/features/auth'
|
||||
import { Spinner } from '@/primitives/Spinner'
|
||||
import { CallbackIdHandler } from '../utils/CallbackIdHandler'
|
||||
import { PopupWindow } from '../utils/PopupWindow'
|
||||
|
||||
const callbackIdHandler = new CallbackIdHandler()
|
||||
const popupWindow = new PopupWindow()
|
||||
|
||||
export const CreatePopup = () => {
|
||||
const { isLoggedIn } = useUser({ fetchUserOptions: { attemptSilent: false } })
|
||||
const { mutateAsync: createRoom } = useCreateRoom()
|
||||
|
||||
const callbackId = useMemo(() => callbackIdHandler.getOrCreate(), [])
|
||||
|
||||
/**
|
||||
* Handle unauthenticated users by redirecting to login
|
||||
*
|
||||
* When redirecting to authentication, the window.location change breaks the connection
|
||||
* between this popup and its parent window. We need to send the callbackId to the parent
|
||||
* before redirecting so it can re-establish connection after authentication completes.
|
||||
* This prevents the popup from becoming orphaned and ensures state consistency.
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (isLoggedIn === false) {
|
||||
// redirection loses the connection to the manager
|
||||
// prevent it passing an async callback id
|
||||
popupWindow.sendCallbackId(callbackId, () => {
|
||||
popupWindow.navigateToAuthentication()
|
||||
})
|
||||
}
|
||||
}, [isLoggedIn, callbackId])
|
||||
|
||||
/**
|
||||
* Automatically create meeting room once user is authenticated
|
||||
* This effect will trigger either immediately if the user is already logged in,
|
||||
* or after successful authentication and return to this popup
|
||||
*/
|
||||
useEffect(() => {
|
||||
const createMeetingRoom = async () => {
|
||||
try {
|
||||
const slug = generateRoomId()
|
||||
const roomData = await createRoom({
|
||||
slug,
|
||||
callbackId,
|
||||
})
|
||||
// Send room data back to parent window and clean up resources
|
||||
popupWindow.sendRoomData(roomData, () => {
|
||||
callbackIdHandler.clear()
|
||||
popupWindow.close()
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Failed to create meeting room:', error)
|
||||
}
|
||||
}
|
||||
if (isLoggedIn && callbackId) {
|
||||
createMeetingRoom()
|
||||
}
|
||||
}, [isLoggedIn, callbackId, createRoom])
|
||||
|
||||
return (
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
})}
|
||||
>
|
||||
<Spinner />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
export class CallbackIdHandler {
|
||||
private storageKey = 'popup_callback_id'
|
||||
|
||||
private generateId(): string {
|
||||
return (
|
||||
Math.random().toString(36).substring(2, 15) +
|
||||
Math.random().toString(36).substring(2, 15)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets an existing callback ID or creates a new one
|
||||
*/
|
||||
public getOrCreate(): string {
|
||||
const existingId = this.get()
|
||||
if (existingId) {
|
||||
return existingId
|
||||
}
|
||||
|
||||
const newId = this.generateId()
|
||||
this.set(newId)
|
||||
return newId
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the current callback ID if one exists
|
||||
*/
|
||||
public get(): string | null {
|
||||
return sessionStorage.getItem(this.storageKey)
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets a callback ID
|
||||
*/
|
||||
private set(id: string): void {
|
||||
sessionStorage.setItem(this.storageKey, id)
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes the current callback ID
|
||||
*/
|
||||
public clear(): void {
|
||||
sessionStorage.removeItem(this.storageKey)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { getRouteUrl } from '@/navigation/getRouteUrl'
|
||||
import {
|
||||
CallbackCreationRoomData,
|
||||
ClientMessageType,
|
||||
PopupMessageData,
|
||||
PopupMessageType,
|
||||
} from './types'
|
||||
|
||||
export class PopupManager {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
private messageHandler: (event: MessageEvent<any>) => void = () => {}
|
||||
|
||||
public createPopupWindow(onFailure: () => void) {
|
||||
const popupWindow = window.open(
|
||||
`${window.location.origin}/sdk/create-popup`,
|
||||
'CreatePopupWindow',
|
||||
`status=no,location=no,toolbar=no,menubar=no,width=600,height=800,left=100,top=100, resizable=yes,scrollbars=yes`
|
||||
)
|
||||
|
||||
if (popupWindow) {
|
||||
popupWindow.focus()
|
||||
} else {
|
||||
onFailure()
|
||||
}
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
private messageParent(type: ClientMessageType, data: any) {
|
||||
window?.parent.postMessage(
|
||||
{
|
||||
type: type,
|
||||
data: data,
|
||||
},
|
||||
'*'
|
||||
)
|
||||
}
|
||||
|
||||
public clearState() {
|
||||
this.messageParent(ClientMessageType.STATE_CLEAR, {})
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
public sendRoomData(data: { room: any }) {
|
||||
this.messageParent(ClientMessageType.ROOM_CREATED, data)
|
||||
}
|
||||
|
||||
public setupMessageListener(
|
||||
onCallbackId: (id: string) => void,
|
||||
onRoomData: (data: CallbackCreationRoomData) => void
|
||||
) {
|
||||
this.messageHandler = (event) => {
|
||||
const data = event.data as PopupMessageData
|
||||
// Skip messages from untrusted sources
|
||||
if (data.source !== window.location.origin) return
|
||||
switch (data.type) {
|
||||
case PopupMessageType.CALLBACK_ID:
|
||||
onCallbackId(data.callbackId as string)
|
||||
return
|
||||
case PopupMessageType.ROOM_DATA:
|
||||
if (!data?.room) return
|
||||
onRoomData(data.room)
|
||||
this.sendRoomData({
|
||||
room: {
|
||||
url: getRouteUrl('room', data.room.slug),
|
||||
...data.room,
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
window.addEventListener('message', this.messageHandler)
|
||||
}
|
||||
|
||||
public cleanup() {
|
||||
window.removeEventListener('message', this.messageHandler)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { authUrl } from '@/features/auth'
|
||||
import { PopupMessageType, CallbackCreationRoomData } from './types'
|
||||
|
||||
export class PopupWindow {
|
||||
private sendMessageToManager(
|
||||
type: PopupMessageType,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
data: any,
|
||||
callback?: () => void
|
||||
) {
|
||||
if (!window.opener) {
|
||||
console.error('No manager window found')
|
||||
window.close()
|
||||
return
|
||||
}
|
||||
window.opener.postMessage(
|
||||
{
|
||||
source: window.location.origin,
|
||||
type,
|
||||
...data,
|
||||
},
|
||||
window.location.origin
|
||||
)
|
||||
callback?.()
|
||||
}
|
||||
|
||||
public sendRoomData(data: CallbackCreationRoomData, callback?: () => void) {
|
||||
this.sendMessageToManager(
|
||||
PopupMessageType.ROOM_DATA,
|
||||
{ room: { slug: data.slug } },
|
||||
callback
|
||||
)
|
||||
}
|
||||
|
||||
public sendCallbackId(callbackId: string, callback?: () => void) {
|
||||
this.sendMessageToManager(
|
||||
PopupMessageType.CALLBACK_ID,
|
||||
{ callbackId },
|
||||
callback
|
||||
)
|
||||
}
|
||||
|
||||
public close() {
|
||||
window.close()
|
||||
}
|
||||
|
||||
public navigateToAuthentication() {
|
||||
window.location.href = authUrl({})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
export type CallbackCreationRoomData = {
|
||||
slug: string
|
||||
}
|
||||
|
||||
export enum ClientMessageType {
|
||||
ROOM_CREATED = 'ROOM_CREATED',
|
||||
STATE_CLEAR = 'STATE_CLEAR',
|
||||
}
|
||||
|
||||
export interface PopupMessageData {
|
||||
type: PopupMessageType
|
||||
source: string
|
||||
callbackId?: string
|
||||
room?: CallbackCreationRoomData
|
||||
}
|
||||
|
||||
export enum PopupMessageType {
|
||||
CALLBACK_ID,
|
||||
ROOM_DATA,
|
||||
}
|
||||
@@ -18,15 +18,16 @@ export const terminateSupportSession = () => {
|
||||
|
||||
export type useSupportProps = {
|
||||
id?: string
|
||||
isDisabled?: boolean
|
||||
}
|
||||
|
||||
// Configure Crisp chat for real-time support across all pages.
|
||||
export const useSupport = ({ id }: useSupportProps) => {
|
||||
export const useSupport = ({ id, isDisabled }: useSupportProps) => {
|
||||
useEffect(() => {
|
||||
if (!id || Crisp.isCrispInjected()) return
|
||||
if (!id || Crisp.isCrispInjected() || isDisabled) return
|
||||
Crisp.configure(id)
|
||||
Crisp.setHideOnMobile(true)
|
||||
}, [id])
|
||||
}, [id, isDisabled])
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -106,7 +106,7 @@ const Marianne = () => {
|
||||
className={css({
|
||||
letterSpacing: '-.01em',
|
||||
textTransform: 'uppercase',
|
||||
fontWeight: '600',
|
||||
fontWeight: '700',
|
||||
fontFamily: 'Marianne',
|
||||
fontSize: '1.25rem',
|
||||
lineHeight: '1.75rem',
|
||||
|
||||
@@ -44,7 +44,7 @@ const Marianne = () => {
|
||||
className={css({
|
||||
letterSpacing: '-.01em',
|
||||
textTransform: 'uppercase',
|
||||
fontWeight: '600',
|
||||
fontWeight: '700',
|
||||
fontFamily: 'Marianne',
|
||||
fontSize: '0.7875rem !important',
|
||||
})}
|
||||
|
||||
@@ -21,5 +21,13 @@
|
||||
"several": "",
|
||||
"open": "",
|
||||
"accept": ""
|
||||
},
|
||||
"transcript": {
|
||||
"started": "",
|
||||
"stopped": ""
|
||||
},
|
||||
"screenRecording": {
|
||||
"started": "",
|
||||
"stopped": ""
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,6 +60,17 @@
|
||||
"createRoom": {
|
||||
"heading": "",
|
||||
"body": ""
|
||||
},
|
||||
"screenShare": {
|
||||
"title": "",
|
||||
"ariaLabel": "",
|
||||
"message": "",
|
||||
"macInstructions": "",
|
||||
"macSystemPreferences": "",
|
||||
"helpLinkText": "",
|
||||
"helpLinkLabel": "",
|
||||
"closeButton": "",
|
||||
"newTab": ""
|
||||
}
|
||||
},
|
||||
"controls": {
|
||||
@@ -93,7 +104,7 @@
|
||||
"open": "",
|
||||
"closed": ""
|
||||
},
|
||||
"transcript": {
|
||||
"tools": {
|
||||
"open": "",
|
||||
"closed": ""
|
||||
},
|
||||
@@ -101,7 +112,6 @@
|
||||
"open": "",
|
||||
"closed": ""
|
||||
},
|
||||
"support": "",
|
||||
"moreOptions": "",
|
||||
"reactions": {
|
||||
"button": "",
|
||||
@@ -120,26 +130,38 @@
|
||||
"fullscreen": {
|
||||
"enter": "",
|
||||
"exit": ""
|
||||
}
|
||||
},
|
||||
"support": ""
|
||||
}
|
||||
},
|
||||
"effects": {
|
||||
"activateCamera": "",
|
||||
"notAvailable": "",
|
||||
"heading": "",
|
||||
"activateCamera": "Ihre Kamera ist deaktiviert. Wählen Sie eine Option, um sie zu aktivieren.",
|
||||
"notAvailable": "Videoeffekte werden in Kürze in Ihrem Browser verfügbar sein. Wir arbeiten daran! In der Zwischenzeit können Sie Google Chrome für beste Leistung oder Firefox verwenden :(",
|
||||
"heading": "Unschärfe",
|
||||
"blur": {
|
||||
"title": "",
|
||||
"light": "",
|
||||
"normal": "",
|
||||
"apply": "",
|
||||
"clear": ""
|
||||
"title": "Hintergrundunschärfe",
|
||||
"light": "Leichte Unschärfe",
|
||||
"normal": "Unschärfe",
|
||||
"apply": "Unschärfe aktivieren",
|
||||
"clear": "Unschärfe deaktivieren"
|
||||
},
|
||||
"virtual": {
|
||||
"title": "",
|
||||
"apply": "",
|
||||
"clear": ""
|
||||
"title": "Virtueller Hintergrund",
|
||||
"apply": "Virtuellen Hintergrund aktivieren",
|
||||
"clear": "Virtuellen Hintergrund deaktivieren"
|
||||
},
|
||||
"experimental": ""
|
||||
"faceLandmarks": {
|
||||
"title": "Visuelle Effekte",
|
||||
"glasses": {
|
||||
"apply": "Brille hinzufügen",
|
||||
"clear": "Brille entfernen"
|
||||
},
|
||||
"french": {
|
||||
"apply": "Französische Touch hinzufügen",
|
||||
"clear": "Französische Touch entfernen"
|
||||
}
|
||||
},
|
||||
"experimental": "Experimentelle Funktion. Eine v2 kommt für vollständige Browserunterstützung und verbesserte Qualität."
|
||||
},
|
||||
"sidePanel": {
|
||||
"heading": {
|
||||
@@ -147,30 +169,48 @@
|
||||
"effects": "",
|
||||
"chat": "",
|
||||
"transcript": "",
|
||||
"admin": ""
|
||||
"admin": "",
|
||||
"tools": ""
|
||||
},
|
||||
"content": {
|
||||
"participants": "",
|
||||
"effects": "",
|
||||
"chat": "",
|
||||
"transcript": "",
|
||||
"admin": ""
|
||||
"admin": "",
|
||||
"tools": ""
|
||||
},
|
||||
"closeButton": ""
|
||||
},
|
||||
"chat": {
|
||||
"disclaimer": ""
|
||||
},
|
||||
"moreTools": {
|
||||
"body": "",
|
||||
"moreLink": "",
|
||||
"tools": {
|
||||
"transcript": {
|
||||
"title": "",
|
||||
"body": ""
|
||||
}
|
||||
}
|
||||
},
|
||||
"transcript": {
|
||||
"start": {
|
||||
"heading": "",
|
||||
"body": "",
|
||||
"button": ""
|
||||
"button": "",
|
||||
"linkMore": ""
|
||||
},
|
||||
"stop": {
|
||||
"heading": "",
|
||||
"body": "",
|
||||
"button": ""
|
||||
},
|
||||
"beta": {
|
||||
"heading": "",
|
||||
"body": "",
|
||||
"button": ""
|
||||
}
|
||||
},
|
||||
"admin": {
|
||||
@@ -178,6 +218,7 @@
|
||||
"access": {
|
||||
"title": "",
|
||||
"description": "",
|
||||
"type": "",
|
||||
"levels": {
|
||||
"public": {
|
||||
"label": "",
|
||||
@@ -251,8 +292,20 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"recording": {
|
||||
"label": ""
|
||||
"recordingToast": {
|
||||
"transcript": {
|
||||
"started": "",
|
||||
"starting": "",
|
||||
"stopping": ""
|
||||
},
|
||||
"screenRecording": {
|
||||
"started": "",
|
||||
"starting": "",
|
||||
"stopping": ""
|
||||
},
|
||||
"any": {
|
||||
"started": ""
|
||||
}
|
||||
},
|
||||
"participantTileFocus": {
|
||||
"pin": {
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
{
|
||||
"createButton": {
|
||||
"label": ""
|
||||
"createMeeting": {
|
||||
"createButton": "",
|
||||
"joinButton": "",
|
||||
"copyLinkTooltip": "",
|
||||
"resetLabel": "",
|
||||
"participantLimit": "",
|
||||
"popupBlocked": ""
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,5 +21,13 @@
|
||||
"several": "Several people want to join this call.",
|
||||
"open": "Open",
|
||||
"accept": "Accept"
|
||||
},
|
||||
"transcript": {
|
||||
"started": "{{name}} started the meeting transcription.",
|
||||
"stopped": "{{name}} stopped the meeting transcription."
|
||||
},
|
||||
"screenRecording": {
|
||||
"started": "{{name}} started the meeting recording.",
|
||||
"stopped": "{{name}} stopped the meeting recording."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,6 +61,17 @@
|
||||
"createRoom": {
|
||||
"heading": "Authentication Required",
|
||||
"body": "This room has not been created yet. Please authenticate to create it or wait for an authenticated user to do so."
|
||||
},
|
||||
"screenShare": {
|
||||
"title": "Unable to share your screen",
|
||||
"ariaLabel": "Unable to share your screen",
|
||||
"message": "Your browser may not be allowed to record the screen on your computer.",
|
||||
"macInstructions": "Go to your",
|
||||
"macSystemPreferences": "System Preferences",
|
||||
"helpLinkText": "To learn more, see",
|
||||
"helpLinkLabel": "Presentation issue",
|
||||
"closeButton": "Dismiss",
|
||||
"newTab": "New window"
|
||||
}
|
||||
},
|
||||
"controls": {
|
||||
@@ -92,15 +103,14 @@
|
||||
"open": "Hide everyone",
|
||||
"closed": "See everyone"
|
||||
},
|
||||
"transcript": {
|
||||
"open": "Hide AI assistant",
|
||||
"closed": "Show AI assistant"
|
||||
"tools": {
|
||||
"open": "Hide more tools",
|
||||
"closed": "Show more tools"
|
||||
},
|
||||
"admin": {
|
||||
"open": "Hide admin",
|
||||
"closed": "Open admin"
|
||||
},
|
||||
"support": "Support",
|
||||
"moreOptions": "More options",
|
||||
"reactions": {
|
||||
"button": "Send reaction",
|
||||
@@ -119,7 +129,8 @@
|
||||
"fullscreen": {
|
||||
"enter": "Fullscreen",
|
||||
"exit": "Exit fullscreen mode"
|
||||
}
|
||||
},
|
||||
"support": "Contact support"
|
||||
}
|
||||
},
|
||||
"effects": {
|
||||
@@ -138,6 +149,17 @@
|
||||
"apply": "Enable virtual background",
|
||||
"clear": "Disable virtual background"
|
||||
},
|
||||
"faceLandmarks": {
|
||||
"title": "Visual Effects",
|
||||
"glasses": {
|
||||
"apply": "Add Glasses",
|
||||
"clear": "Remove Glasses"
|
||||
},
|
||||
"french": {
|
||||
"apply": "Add French touch",
|
||||
"clear": "Remove French touch"
|
||||
}
|
||||
},
|
||||
"experimental": "Experimental feature. A v2 is coming for full browser support and improved quality."
|
||||
},
|
||||
"sidePanel": {
|
||||
@@ -145,31 +167,68 @@
|
||||
"participants": "Participants",
|
||||
"effects": "Effects",
|
||||
"chat": "Messages in the chat",
|
||||
"transcript": "AI Assistant",
|
||||
"admin": "Admin settings"
|
||||
"transcript": "Transcription",
|
||||
"screenRecording": "Recording",
|
||||
"admin": "Admin settings",
|
||||
"tools": "More tools"
|
||||
},
|
||||
"content": {
|
||||
"participants": "participants",
|
||||
"effects": "effects",
|
||||
"chat": "messages",
|
||||
"transcript": "AI assistant",
|
||||
"admin": "Admin settings"
|
||||
"transcript": "transcription",
|
||||
"screenRecording": "recording",
|
||||
"admin": "admin settings",
|
||||
"tools": "more tools"
|
||||
},
|
||||
"closeButton": "Hide {{content}}"
|
||||
},
|
||||
"chat": {
|
||||
"disclaimer": "The messages are visible to participants only at the time they are sent. All messages are deleted at the end of the call."
|
||||
},
|
||||
"moreTools": {
|
||||
"body": "Access more tools in Visio to enhance your meetings,",
|
||||
"moreLink": "learn more",
|
||||
"tools": {
|
||||
"transcript": {
|
||||
"title": "Transcription",
|
||||
"body": "Keep a written record of your meeting."
|
||||
},
|
||||
"screenRecording": {
|
||||
"title": "Recording",
|
||||
"body": "Record your meeting to watch it again whenever you like."
|
||||
}
|
||||
}
|
||||
},
|
||||
"transcript": {
|
||||
"start": {
|
||||
"heading": "Start the Assistant!",
|
||||
"body": "The assistant automatically starts recording your meeting audio (limited to 1 hour). At the end, you'll receive a clear and concise summary of the discussion directly via email.",
|
||||
"button": "Start"
|
||||
"heading": "Transcribe this call",
|
||||
"body": "Automatically transcribe this call and receive the summary in Docs.",
|
||||
"button": "Start transcription",
|
||||
"linkMore": "Learn more"
|
||||
},
|
||||
"stop": {
|
||||
"heading": "Recording in Progress...",
|
||||
"body": "Your meeting is currently being recorded. You will receive a summary via email once the meeting ends.",
|
||||
"button": "Stop Recording"
|
||||
"heading": "Transcription in progress...",
|
||||
"body": "The transcription of your meeting is in progress. You will receive the result by email once the meeting is finished.",
|
||||
"button": "Stop transcription"
|
||||
},
|
||||
"beta": {
|
||||
"heading": "Become a beta tester",
|
||||
"body": "Record your meeting for later. You will receive a summary by email once the meeting is finished.",
|
||||
"button": "Sign up"
|
||||
}
|
||||
},
|
||||
"screenRecording": {
|
||||
"start": {
|
||||
"heading": "Record this call",
|
||||
"body": "Record this call to watch it later and receive the video recording by email.",
|
||||
"button": "Start recording",
|
||||
"linkMore": "Learn more"
|
||||
},
|
||||
"stop": {
|
||||
"heading": "Recording in progress…",
|
||||
"body": "You will receive the result by email once the recording is complete.",
|
||||
"button": "Stop recording"
|
||||
}
|
||||
},
|
||||
"admin": {
|
||||
@@ -177,6 +236,7 @@
|
||||
"access": {
|
||||
"title": "Room access",
|
||||
"description": "These settings will also apply to future occurrences of this meeting.",
|
||||
"type": "Meeting access types",
|
||||
"levels": {
|
||||
"public": {
|
||||
"label": "Open",
|
||||
@@ -250,8 +310,20 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"recording": {
|
||||
"label": "Recording"
|
||||
"recordingToast": {
|
||||
"transcript": {
|
||||
"started": "Transcribing",
|
||||
"starting": "Transcription starting",
|
||||
"stopping": "Transcription stopping"
|
||||
},
|
||||
"screenRecording": {
|
||||
"started": "Recording in progress",
|
||||
"starting": "Starting recording",
|
||||
"stopping": "Stopping recording"
|
||||
},
|
||||
"any": {
|
||||
"started": "Recording in progress"
|
||||
}
|
||||
},
|
||||
"participantTileFocus": {
|
||||
"pin": {
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
{
|
||||
"createButton": {
|
||||
"label": "Create a Visio link"
|
||||
"createMeeting": {
|
||||
"createButton": "Create",
|
||||
"joinButton": "Join with Visio",
|
||||
"copyLinkTooltip": "Copy link",
|
||||
"resetLabel": "Reset",
|
||||
"participantLimit": "Up to 150 participants.",
|
||||
"popupBlocked": "Popup was blocked. Please allow popups for this site."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,5 +21,13 @@
|
||||
"several": "Plusieurs personnes souhaitent participer à cet appel.",
|
||||
"open": "Afficher",
|
||||
"accept": "Accepter"
|
||||
},
|
||||
"transcript": {
|
||||
"started": "{{name}} a démarré la transcription de la réunion.",
|
||||
"stopped": "{{name}} a arrêté la transcription de la réunion."
|
||||
},
|
||||
"screenRecording": {
|
||||
"started": "{{name}} a démarré l'enregistrement de la réunion.",
|
||||
"stopped": "{{name}} a arrêté l'enregistrement de la réunion."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,6 +61,17 @@
|
||||
"createRoom": {
|
||||
"heading": "Authentification requise",
|
||||
"body": "Cette réunion n'a pas encore été créée. Veuillez vous authentifier pour la créer ou attendre qu'un utilisateur authentifié le fasse."
|
||||
},
|
||||
"screenShare": {
|
||||
"title": "Impossible de partager votre écran",
|
||||
"ariaLabel": "Impossible de partager votre écran",
|
||||
"message": "Il se peut que votre navigateur ne soit pas autorisé à enregistrer l'écran sur votre ordinateur.",
|
||||
"macInstructions": "Accèdez à vos",
|
||||
"macSystemPreferences": "Préférences système",
|
||||
"helpLinkText": "Pour en savoir plus, consulter",
|
||||
"helpLinkLabel": "Problème de présentation",
|
||||
"closeButton": "Ignorer",
|
||||
"newTab": "Nouvelle fenêtre"
|
||||
}
|
||||
},
|
||||
"controls": {
|
||||
@@ -92,15 +103,14 @@
|
||||
"open": "Masquer les participants",
|
||||
"closed": "Afficher les participants"
|
||||
},
|
||||
"transcript": {
|
||||
"open": "Masquer l'assistant IA",
|
||||
"closed": "Afficher l'assistant IA"
|
||||
"tools": {
|
||||
"open": "Masquer plus d'outils",
|
||||
"closed": "Afficher plus d'outils"
|
||||
},
|
||||
"admin": {
|
||||
"open": "Masquer l'admin",
|
||||
"closed": "Afficher l'admin"
|
||||
},
|
||||
"support": "Support",
|
||||
"moreOptions": "Plus d'options",
|
||||
"reactions": {
|
||||
"button": "Envoyer une réaction",
|
||||
@@ -119,56 +129,105 @@
|
||||
"fullscreen": {
|
||||
"enter": "Plein écran",
|
||||
"exit": "Quitter le mode plein écran"
|
||||
}
|
||||
},
|
||||
"support": "Contacter l'aide"
|
||||
}
|
||||
},
|
||||
"effects": {
|
||||
"activateCamera": "Votre camera est désactivée. Choisissez une option pour l'activer.",
|
||||
"notAvailable": "Les effets vidéo seront bientôt disponible sur votre navigateur. Nous y travaillons ! En attendant, vous pouvez utiliser Google Chrome pour une meilleure performance ou Firefox :(",
|
||||
"activateCamera": "Votre caméra est désactivée. Choisissez une option pour l'activer.",
|
||||
"notAvailable": "Les effets vidéo seront bientôt disponibles sur votre navigateur. Nous y travaillons ! En attendant, vous pouvez utiliser Google Chrome pour de meilleures performances ou Firefox :(",
|
||||
"heading": "Flou",
|
||||
"blur": {
|
||||
"title": "Flou d'arrière-plan",
|
||||
"light": "Léger flou",
|
||||
"light": "Flou léger",
|
||||
"normal": "Flou",
|
||||
"apply": "Appliquer le flou",
|
||||
"apply": "Activer le flou",
|
||||
"clear": "Désactiver le flou"
|
||||
},
|
||||
"virtual": {
|
||||
"title": "Arrière-plan virtuel",
|
||||
"apply": "Appliquer l'arrière plan virtuel",
|
||||
"clear": "Désactiver l'arrière plan virtuel"
|
||||
"apply": "Activer l'arrière-plan virtuel",
|
||||
"clear": "Désactiver l'arrière-plan virtuel"
|
||||
},
|
||||
"experimental": "Fonctionnalité expérimentale. Une v2 arrive pour un support complet sur tous les navigateurs et une meilleur qualité."
|
||||
"faceLandmarks": {
|
||||
"title": "Effets visuels",
|
||||
"glasses": {
|
||||
"apply": "Ajouter des lunettes",
|
||||
"clear": "Retirer les lunettes"
|
||||
},
|
||||
"french": {
|
||||
"apply": "Ajouter la touche française",
|
||||
"clear": "Retirer la touche française"
|
||||
}
|
||||
},
|
||||
"experimental": "Fonctionnalité expérimentale. Une v2 arrive pour un support complet des navigateurs et une meilleure qualité."
|
||||
},
|
||||
"sidePanel": {
|
||||
"heading": {
|
||||
"participants": "Participants",
|
||||
"effects": "Effets",
|
||||
"chat": "Messages dans l'appel",
|
||||
"transcript": "Assistant IA",
|
||||
"admin": "Commandes de l'organisateur"
|
||||
"transcript": "Transcription",
|
||||
"screenRecording": "Enregistrement",
|
||||
"admin": "Commandes de l'organisateur",
|
||||
"tools": "Plus d'outils"
|
||||
},
|
||||
"content": {
|
||||
"participants": "les participants",
|
||||
"effects": "les effets",
|
||||
"chat": "les messages",
|
||||
"transcript": "l'assistant IA",
|
||||
"admin": "Commandes de l'organisateur"
|
||||
"transcript": "transcription",
|
||||
"screenRecording": "enregistrement",
|
||||
"admin": "commandes de l'organisateur",
|
||||
"tools": "plus d'outils"
|
||||
},
|
||||
"closeButton": "Masquer {{content}}"
|
||||
},
|
||||
"chat": {
|
||||
"disclaimer": "Les messages sont visibles par les participants uniquement au moment de\nleur envoi. Tous les messages sont supprimés à la fin de l'appel."
|
||||
},
|
||||
"moreTools": {
|
||||
"body": "Accèder à d'avantage d'outils dans Visio pour améliorer vos réunions,",
|
||||
"moreLink": "en savoir plus",
|
||||
"tools": {
|
||||
"transcript": {
|
||||
"title": "Transcription",
|
||||
"body": "Conservez une trace écrite de votre réunion."
|
||||
},
|
||||
"screenRecording": {
|
||||
"title": "Enregistrement",
|
||||
"body": "Enregistrez votre réunion pour la revoir quand vous le souhaitez."
|
||||
}
|
||||
}
|
||||
},
|
||||
"transcript": {
|
||||
"start": {
|
||||
"heading": "Démarrer l'assistant !",
|
||||
"body": "L'assistant démarre automatiquement l'enregistrement sonore de votre réunion (limité à 1h). À la fin, vous recevrez un résumé clair et concis des échanges directement par e-mail.",
|
||||
"button": "Démarrer"
|
||||
"heading": "Transcrire cet appel",
|
||||
"body": "Transcrivez cet appel automatiquement et recevez le compte rendu dans Docs.",
|
||||
"button": "Démarrer la transcription",
|
||||
"linkMore": "En savoir plus"
|
||||
},
|
||||
"stop": {
|
||||
"heading": "Transcription en cours …",
|
||||
"body": "La transcription de votre réunion est en cours. Vous recevrez le resultat par email une fois la réunion terminée.",
|
||||
"button": "Arrêter la transcription"
|
||||
},
|
||||
"beta": {
|
||||
"heading": "Devenez beta testeur",
|
||||
"body": "Enregistrer votre réunion pour plus tard. Vous recevrez un compte-rendu par email une fois la réunion terminée.",
|
||||
"button": "Inscrivez-vous"
|
||||
}
|
||||
},
|
||||
"screenRecording": {
|
||||
"start": {
|
||||
"heading": "Enregistrer cet appel",
|
||||
"body": "Enregistrez cet appel pour plus tard et recevez l'enregistrement vidéo par mail.",
|
||||
"button": "Démarrer l'enregistrement",
|
||||
"linkMore": "En savoir plus"
|
||||
},
|
||||
"stop": {
|
||||
"heading": "Enregistrement en cours …",
|
||||
"body": "L'enregistrement de votre réunion est en cours. Vous recevrez un compte-rendu par email une fois la réunion terminée.",
|
||||
"body": "Vous recevrez le resultat par email une fois l'enregistrement terminé.",
|
||||
"button": "Arrêter l'enregistrement"
|
||||
}
|
||||
},
|
||||
@@ -176,11 +235,12 @@
|
||||
"description": "Ces paramètres organisateur vous permettent de garder le contrôle de votre réunion. Seuls les organisateurs peuvent accéder à ces commandes.",
|
||||
"access": {
|
||||
"title": "Accès à la réunion",
|
||||
"description": "Ces paramètres s'appliqueront également aux futures occurences de cette réunion.",
|
||||
"description": "Ces paramètres s'appliqueront également aux futures occurrences de cette réunion.",
|
||||
"type": "Type d'accès à la réunion",
|
||||
"levels": {
|
||||
"public": {
|
||||
"label": "Ouvrir",
|
||||
"description": "Persone n'a à demander pour rejoindre la réunion."
|
||||
"description": "Tout le monde peut rejoindre la réunion sans autorisation."
|
||||
},
|
||||
"trusted": {
|
||||
"label": "Ouvrir aux personnes de confiance",
|
||||
@@ -250,8 +310,20 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"recording": {
|
||||
"label": "Enregistrement"
|
||||
"recordingToast": {
|
||||
"transcript": {
|
||||
"started": "Transcription en cours",
|
||||
"starting": "Démarrage de la transcription",
|
||||
"stopping": "Arrêt de la transcription"
|
||||
},
|
||||
"screenRecording": {
|
||||
"started": "Enregistrement en cours",
|
||||
"starting": "Démarrage de l'enregistrement",
|
||||
"stopping": "Arrêt de l'enregistrement"
|
||||
},
|
||||
"any": {
|
||||
"started": "Enregistrement en cours"
|
||||
}
|
||||
},
|
||||
"participantTileFocus": {
|
||||
"pin": {
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
{
|
||||
"createButton": {
|
||||
"label": "Créer un lien Visio"
|
||||
"createMeeting": {
|
||||
"createButton": "Créer un lien",
|
||||
"joinButton": "Participer avec Visio",
|
||||
"copyLinkTooltip": "Copier le lien",
|
||||
"resetLabel": "Réinitialiser",
|
||||
"participantLimit": "Jusqu'à 150 participants.",
|
||||
"popupBlocked": "La fenêtre pop-up a été bloquée. Veuillez autoriser les pop-ups pour ce site."
|
||||
}
|
||||
}
|
||||
|
||||