Compare commits
107 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4232c0a303 | |||
| a5454e48b7 | |||
| 591706f363 | |||
| 1ea84b6e6c | |||
| c0d101a326 | |||
| 64cfcb6c0f | |||
| cdd69b741a | |||
| f5a87cc210 | |||
| 4d4ddb9ee8 | |||
| 70dbf94f7b | |||
| 0ad37ee6de | |||
| ed4f7dcf6c | |||
| c54773008c | |||
| 6848321bcc | |||
| f161a5cf6a | |||
| 51270571bc | |||
| c6fdeaf1e9 | |||
| f1959cbb3a | |||
| 84cea2f658 | |||
| db1fdb9871 | |||
| a8618239d1 | |||
| 0eb283b75a | |||
| 0104cabc5e | |||
| dbfba564c5 | |||
| 4830206bb2 | |||
| e4f30f926c | |||
| fc61f58596 | |||
| e0021dbb80 | |||
| 83914f8307 | |||
| be54709598 | |||
| 5b76ea492b | |||
| 46934a84d1 | |||
| deb9ab1a1d | |||
| d0fd16d7d2 | |||
| 0b8181e5ce | |||
| 8b2365d5f9 | |||
| 2dd16d1f40 | |||
| fa9484b630 | |||
| 1e0e495cd8 | |||
| c270299179 | |||
| d9a84e5f0f | |||
| 0f64d3cf3a | |||
| d1e008a844 | |||
| 3be5a5afc6 | |||
| 31468a5e7c | |||
| b342b9d526 | |||
| f7e7c3ba22 | |||
| 3902b02691 | |||
| 7ce4390740 | |||
| 94d18cffe4 | |||
| ad0c3eea66 | |||
| 459bbf65a8 | |||
| 4b4f16e93f | |||
| 3e3f964ac5 | |||
| bcb111defb | |||
| 91a3aa4033 | |||
| 05b74365fa | |||
| 4eea4d01cd | |||
| 8caa6b7820 | |||
| e7e7bb0d09 | |||
| 819d3784f7 | |||
| d83ccb3d9d | |||
| a083c8cc3a | |||
| a299cae8e8 | |||
| 60b7a35ed1 | |||
| 39fdffd9a8 | |||
| e3779d9a6e | |||
| b74d6e72f9 | |||
| 9be826fd14 | |||
| 4fe01ae2bf | |||
| 300756b323 | |||
| 0af79912c5 | |||
| 446270c153 | |||
| e6377ce182 | |||
| a6b6cb7787 | |||
| 50146e95f4 | |||
| 35741a1bc1 | |||
| b12b14b277 | |||
| bb30f25953 | |||
| ebbdebf444 | |||
| fde10e7556 | |||
| 0dbb256e9f | |||
| 33c8a3a9e4 | |||
| dd0cb61ebc | |||
| e92f084afb | |||
| 4a53005ae3 | |||
| d7d82130d9 | |||
| 16bee00eb4 | |||
| 0f70b544ed | |||
| 2903442a67 | |||
| be884a8ca1 | |||
| fe70165076 | |||
| 290ba4274a | |||
| d2f239e6a2 | |||
| c61fc40671 | |||
| 10705ca3ac | |||
| 6b03ebb393 | |||
| 0627510f10 | |||
| f9233c8fb3 | |||
| 0b672ef8da | |||
| 0b0efa5bab | |||
| 0958206057 | |||
| 4e13ace815 | |||
| 30ee0fdce3 | |||
| 17453a6cee | |||
| c1e8ebc129 | |||
| a1dbf02f9c |
@@ -117,10 +117,57 @@ jobs:
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
|
||||
build-and-push-summary:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
-
|
||||
uses: actions/create-github-app-token@v1
|
||||
id: app-token
|
||||
with:
|
||||
app-id: ${{ secrets.APP_ID }}
|
||||
private-key: ${{ secrets.PRIVATE_KEY }}
|
||||
owner: ${{ github.repository_owner }}
|
||||
repositories: "meet,secrets"
|
||||
-
|
||||
name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: recursive
|
||||
token: ${{ steps.app-token.outputs.token }}
|
||||
-
|
||||
name: Load sops secrets
|
||||
uses: rouja/actions-sops@main
|
||||
with:
|
||||
secret-file: secrets/numerique-gouv/meet/secrets.enc.env
|
||||
age-key: ${{ secrets.SOPS_PRIVATE }}
|
||||
-
|
||||
name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: lasuite/meet-summary
|
||||
-
|
||||
name: Login to DockerHub
|
||||
if: github.event_name != 'pull_request'
|
||||
run: echo "$DOCKER_HUB_PASSWORD" | docker login -u "$DOCKER_HUB_USER" --password-stdin
|
||||
-
|
||||
name: Build and push
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: ./src/summary
|
||||
file: ./src/summary/Dockerfile
|
||||
target: production
|
||||
build-args: DOCKER_USER=${{ env.DOCKER_USER }}:-1000
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
|
||||
|
||||
notify-argocd:
|
||||
needs:
|
||||
- build-and-push-frontend
|
||||
- build-and-push-backend
|
||||
- build-and-push-summary
|
||||
runs-on: ubuntu-latest
|
||||
if: |
|
||||
github.event_name != 'pull_request'
|
||||
|
||||
@@ -302,7 +302,9 @@ build-k8s-cluster: ## build the kubernetes cluster using kind
|
||||
.PHONY: build-k8s-cluster
|
||||
|
||||
start-tilt: ## start the kubernetes cluster using kind
|
||||
kubectl config set-context --current --namespace=meet
|
||||
tilt up -f ./bin/Tiltfile
|
||||
.PHONY: build-k8s-cluster
|
||||
|
||||
start-tilt-keycloak: ## start the kubernetes cluster using kind, without Pro Connect for authentication, use keycloak
|
||||
DEV_ENV=dev-keycloak tilt up -f ./bin/Tiltfile
|
||||
.PHONY: build-k8s-cluster
|
||||
|
||||
@@ -118,6 +118,8 @@ $ make build-k8s-cluster
|
||||
Once the Kubernetes cluster is ready, start the application stack locally:
|
||||
```shell
|
||||
$ make start-tilt
|
||||
or
|
||||
$ make start-tilt-keycloak # start stack without Pro Connect, use keycloak
|
||||
```
|
||||
These commands set up and run your application environment using Tilt for local Kubernetes development.
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
load('ext://uibutton', 'cmd_button', 'bool_input', 'location')
|
||||
load('ext://namespace', 'namespace_create', 'namespace_inject')
|
||||
namespace_create('meet')
|
||||
|
||||
docker_build(
|
||||
'localhost:5001/meet-backend:latest',
|
||||
context='..',
|
||||
@@ -28,7 +27,18 @@ docker_build(
|
||||
]
|
||||
)
|
||||
|
||||
k8s_yaml(local('cd ../src/helm && helmfile -n meet -e dev template .'))
|
||||
docker_build(
|
||||
'localhost:5001/meet-summary:latest',
|
||||
context='../src/summary',
|
||||
dockerfile='../src/summary/Dockerfile',
|
||||
only=['.', '../../docker', '../../.dockerignore'],
|
||||
target = 'production',
|
||||
live_update=[
|
||||
sync('../src/summary', '/home/summary'),
|
||||
]
|
||||
)
|
||||
|
||||
k8s_yaml(local('cd ../src/helm && helmfile -n meet -e ${DEV_ENV:-dev} template .'))
|
||||
|
||||
migration = '''
|
||||
set -eu
|
||||
|
||||
@@ -29,7 +29,7 @@ echo "2. Create kind cluster with containerd registry config dir enabled"
|
||||
# https://github.com/kubernetes-sigs/kind/issues/2875
|
||||
# https://github.com/containerd/containerd/blob/main/docs/cri/config.md#registry-configuration
|
||||
# See: https://github.com/containerd/containerd/blob/main/docs/hosts.md
|
||||
cat <<EOF | kind create cluster --config=-
|
||||
cat <<EOF | kind create cluster --name visio --config=-
|
||||
kind: Cluster
|
||||
apiVersion: kind.x-k8s.io/v1alpha4
|
||||
containerdConfigPatches:
|
||||
@@ -52,10 +52,6 @@ nodes:
|
||||
- containerPort: 443
|
||||
hostPort: 443
|
||||
protocol: TCP
|
||||
- role: worker
|
||||
image: kindest/node:v1.27.3
|
||||
- role: worker
|
||||
image: kindest/node:v1.27.3
|
||||
EOF
|
||||
|
||||
echo "3. Add the registry config to the nodes"
|
||||
@@ -68,7 +64,7 @@ echo "3. Add the registry config to the nodes"
|
||||
# We want a consistent name that works from both ends, so we tell containerd to
|
||||
# alias localhost:${reg_port} to the registry container when pulling images
|
||||
REGISTRY_DIR="/etc/containerd/certs.d/localhost:${reg_port}"
|
||||
for node in $(kind get nodes); do
|
||||
for node in $(kind get nodes --name visio); do
|
||||
docker exec "${node}" mkdir -p "${REGISTRY_DIR}"
|
||||
cat <<EOF | docker exec -i "${node}" cp /dev/stdin "${REGISTRY_DIR}/hosts.toml"
|
||||
[host."http://${reg_name}:5000"]
|
||||
@@ -136,3 +132,8 @@ echo "6. Install ingress-nginx"
|
||||
kubectl apply -f https://raw.githubusercontent.com/kubernetes/ingress-nginx/main/deploy/static/provider/kind/deploy.yaml
|
||||
kubectl -n ingress-nginx create secret tls mkcert --key /tmp/127.0.0.1.nip.io+1-key.pem --cert /tmp/127.0.0.1.nip.io+1.pem
|
||||
kubectl -n ingress-nginx patch deployments.apps ingress-nginx-controller --type 'json' -p '[{"op": "add", "path": "/spec/template/spec/containers/0/args/-", "value":"--default-ssl-certificate=ingress-nginx/mkcert"}]'
|
||||
|
||||
echo "7. Setup namespace"
|
||||
kubectl create ns meet
|
||||
kubectl config set-context --current --namespace=meet
|
||||
kubectl -n meet create secret generic mkcert --from-file=rootCA.pem="$(mkcert -CAROOT)/rootCA.pem"
|
||||
|
||||
@@ -32,6 +32,7 @@ from core.recording.event.exceptions import (
|
||||
InvalidFileTypeError,
|
||||
ParsingEventDataError,
|
||||
)
|
||||
from core.recording.event.notification import notification_service
|
||||
from core.recording.event.parsers import get_parser
|
||||
from core.recording.worker.exceptions import (
|
||||
RecordingStartError,
|
||||
@@ -448,7 +449,17 @@ class RecordingViewSet(
|
||||
" in an error state or has already been saved."
|
||||
)
|
||||
|
||||
recording.status = models.RecordingStatusChoices.SAVED
|
||||
# Attempt to notify external services about the recording
|
||||
# This is a non-blocking operation - failures are logged but don't interrupt the flow
|
||||
notification_succeeded = notification_service.notify_external_services(
|
||||
recording
|
||||
)
|
||||
|
||||
recording.status = (
|
||||
models.RecordingStatusChoices.NOTIFICATION_SUCCEEDED
|
||||
if notification_succeeded
|
||||
else models.RecordingStatusChoices.SAVED
|
||||
)
|
||||
recording.save()
|
||||
|
||||
return drf_response.Response(
|
||||
|
||||
@@ -145,7 +145,7 @@ class OIDCLogoutCallbackView(MozillaOIDCOIDCLogoutView):
|
||||
|
||||
|
||||
class OIDCAuthenticationCallbackView(MozillaOIDCAuthenticationCallbackView):
|
||||
"""Custom callback view for handling the silent loging flow."""
|
||||
"""Custom callback view for handling the silent login flow."""
|
||||
|
||||
@property
|
||||
def failure_url(self):
|
||||
@@ -162,7 +162,7 @@ class OIDCAuthenticationCallbackView(MozillaOIDCAuthenticationCallbackView):
|
||||
|
||||
|
||||
class OIDCAuthenticationRequestView(MozillaOIDCAuthenticationRequestView):
|
||||
"""Custom authentication view for handling the silent loging flow."""
|
||||
"""Custom authentication view for handling the silent login flow."""
|
||||
|
||||
def get_extra_params(self, request):
|
||||
"""Handle 'prompt' extra parameter for the silent login flow
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.1.3 on 2024-12-02 13:23
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0008_user_full_name_user_short_name'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='recording',
|
||||
name='status',
|
||||
field=models.CharField(choices=[('initiated', 'Initiated'), ('active', 'Active'), ('stopped', 'Stopped'), ('saved', 'Saved'), ('aborted', 'Aborted'), ('failed_to_start', 'Failed to Start'), ('failed_to_stop', 'Failed to Stop'), ('notification_succeeded', 'Notification succeeded')], default='initiated', max_length=50),
|
||||
),
|
||||
]
|
||||
@@ -49,6 +49,7 @@ class RecordingStatusChoices(models.TextChoices):
|
||||
ABORTED = "aborted", _("Aborted")
|
||||
FAILED_TO_START = "failed_to_start", _("Failed to Start")
|
||||
FAILED_TO_STOP = "failed_to_stop", _("Failed to Stop")
|
||||
NOTIFICATION_SUCCEEDED = "notification_succeeded", _("Notification succeeded")
|
||||
|
||||
@classmethod
|
||||
def is_final(cls, status):
|
||||
@@ -462,7 +463,23 @@ class BaseAccess(BaseModel):
|
||||
|
||||
|
||||
class Recording(BaseModel):
|
||||
"""Model for recordings that take place in a room"""
|
||||
"""Model for recordings that take place in a room.
|
||||
|
||||
Recording Status Flow:
|
||||
1. INITIATED: Initial state when recording is requested
|
||||
2. ACTIVE: Recording is currently in progress
|
||||
3. STOPPED: Recording has been stopped by user/system
|
||||
4. SAVED: Recording has been successfully processed and stored
|
||||
4. NOTIFICATION_SUCCEEDED: External service has been notified of this recording
|
||||
|
||||
Error States:
|
||||
- FAILED_TO_START: Worker failed to initialize recording
|
||||
- FAILED_TO_STOP: Worker failed during stop operation
|
||||
- ABORTED: Recording was terminated before completion
|
||||
|
||||
Warning: Worker failures may lead to database inconsistency between the actual
|
||||
recording state and its status in the database.
|
||||
"""
|
||||
|
||||
room = models.ForeignKey(
|
||||
Room,
|
||||
@@ -471,7 +488,7 @@ class Recording(BaseModel):
|
||||
verbose_name=_("Room"),
|
||||
)
|
||||
status = models.CharField(
|
||||
max_length=20,
|
||||
max_length=50,
|
||||
choices=RecordingStatusChoices.choices,
|
||||
default=RecordingStatusChoices.INITIATED,
|
||||
)
|
||||
@@ -543,22 +560,7 @@ class Recording(BaseModel):
|
||||
|
||||
|
||||
class RecordingAccess(BaseAccess):
|
||||
"""Relation model to give access to a recording for a user or a team with a role.
|
||||
|
||||
Recording Status Flow:
|
||||
1. INITIATED: Initial state when recording is requested
|
||||
2. ACTIVE: Recording is currently in progress
|
||||
3. STOPPED: Recording has been stopped by user/system
|
||||
4. SAVED: Recording has been successfully processed and stored
|
||||
|
||||
Error States:
|
||||
- FAILED_TO_START: Worker failed to initialize recording
|
||||
- FAILED_TO_STOP: Worker failed during stop operation
|
||||
- ABORTED: Recording was terminated before completion
|
||||
|
||||
Warning: Worker failures may lead to database inconsistency between the actual
|
||||
recording state and its status in the database.
|
||||
"""
|
||||
"""Relation model to give access to a recording for a user or a team with a role."""
|
||||
|
||||
recording = models.ForeignKey(
|
||||
Recording,
|
||||
|
||||
@@ -47,8 +47,6 @@ class StorageEventAuthentication(BaseAuthentication):
|
||||
|
||||
def authenticate(self, request):
|
||||
"""Validate the Bearer token from the Authorization header."""
|
||||
if not settings.RECORDING_ENABLE_STORAGE_EVENT_AUTH:
|
||||
return MachineUser(), None
|
||||
|
||||
if not settings.RECORDING_ENABLE_STORAGE_EVENT_AUTH:
|
||||
return MachineUser(), None
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
"""Service to notify external services when a new recording is ready."""
|
||||
|
||||
import logging
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
import requests
|
||||
|
||||
from core import models
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class NotificationService:
|
||||
"""Service for processing recordings and notifying external services."""
|
||||
|
||||
def notify_external_services(self, recording):
|
||||
"""Process a recording based on its mode."""
|
||||
|
||||
if recording.mode == models.RecordingModeChoices.TRANSCRIPT:
|
||||
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
|
||||
|
||||
logger.error(
|
||||
"Unknown recording mode %s for recording %s",
|
||||
recording.mode,
|
||||
recording.id,
|
||||
)
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def _notify_summary_service(recording):
|
||||
"""Notify summary service about a new recording."""
|
||||
|
||||
if (
|
||||
not settings.SUMMARY_SERVICE_ENDPOINT
|
||||
or not settings.SUMMARY_SERVICE_API_TOKEN
|
||||
):
|
||||
logger.error("Summary service not configured")
|
||||
return False
|
||||
|
||||
owner_access = (
|
||||
models.RecordingAccess.objects.select_related("user")
|
||||
.filter(
|
||||
role=models.RoleChoices.OWNER,
|
||||
recording_id=recording.id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
if not owner_access:
|
||||
logger.error("No owner found for recording %s", recording.id)
|
||||
return False
|
||||
|
||||
key = f"{settings.RECORDING_OUTPUT_FOLDER}/{recording.id}.ogg"
|
||||
|
||||
payload = {
|
||||
"filename": key,
|
||||
"email": owner_access.user.email,
|
||||
"sub": owner_access.user.sub,
|
||||
}
|
||||
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": f"Bearer {settings.SUMMARY_SERVICE_API_TOKEN}",
|
||||
}
|
||||
|
||||
try:
|
||||
response = requests.post(
|
||||
settings.SUMMARY_SERVICE_ENDPOINT,
|
||||
json=payload,
|
||||
headers=headers,
|
||||
timeout=30,
|
||||
)
|
||||
response.raise_for_status()
|
||||
except requests.HTTPError as exc:
|
||||
logger.exception(
|
||||
"Summary service HTTP error for recording %s. URL: %s. Exception: %s",
|
||||
recording.id,
|
||||
settings.SUMMARY_SERVICE_ENDPOINT,
|
||||
exc,
|
||||
)
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
notification_service = NotificationService()
|
||||
@@ -324,8 +324,10 @@ class Base(Configuration):
|
||||
OIDC_AUTHENTICATE_CLASS = "core.authentication.views.OIDCAuthenticationRequestView"
|
||||
OIDC_CALLBACK_CLASS = "core.authentication.views.OIDCAuthenticationCallbackView"
|
||||
OIDC_CREATE_USER = values.BooleanValue(
|
||||
default=True,
|
||||
environ_name="OIDC_CREATE_USER",
|
||||
default=True, environ_name="OIDC_CREATE_USER", environ_prefix=None
|
||||
)
|
||||
OIDC_VERIFY_SSL = values.BooleanValue(
|
||||
default=True, environ_name="OIDC_VERIFY_SSL", environ_prefix=None
|
||||
)
|
||||
OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION = values.BooleanValue(
|
||||
default=False,
|
||||
@@ -446,7 +448,13 @@ class Base(Configuration):
|
||||
False, environ_name="RECORDING_STORAGE_EVENT_ENABLE", environ_prefix=None
|
||||
)
|
||||
RECORDING_STORAGE_EVENT_TOKEN = values.Value(
|
||||
None, environ_name="RECORDING_STORAGE_HOOK_TOKEN", environ_prefix=None
|
||||
None, environ_name="RECORDING_STORAGE_EVENT_TOKEN", environ_prefix=None
|
||||
)
|
||||
SUMMARY_SERVICE_ENDPOINT = values.Value(
|
||||
None, environ_name="SUMMARY_SERVICE_ENDPOINT", environ_prefix=None
|
||||
)
|
||||
SUMMARY_SERVICE_API_TOKEN = values.Value(
|
||||
None, environ_name="SUMMARY_SERVICE_API_TOKEN", environ_prefix=None
|
||||
)
|
||||
|
||||
# pylint: disable=invalid-name
|
||||
@@ -495,8 +503,10 @@ class Base(Configuration):
|
||||
release=get_release(),
|
||||
integrations=[DjangoIntegration()],
|
||||
)
|
||||
with sentry_sdk.configure_scope() as scope:
|
||||
scope.set_extra("application", "backend")
|
||||
|
||||
# Add the application name to the Sentry scope
|
||||
scope = sentry_sdk.get_global_scope()
|
||||
scope.set_tag("application", "backend")
|
||||
|
||||
|
||||
class Build(Base):
|
||||
|
||||
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "meet"
|
||||
version = "0.1.8"
|
||||
version = "0.1.12"
|
||||
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
|
||||
classifiers = [
|
||||
"Development Status :: 5 - Production/Stable",
|
||||
@@ -25,20 +25,20 @@ license = { file = "LICENSE" }
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
dependencies = [
|
||||
"boto3==1.35.19",
|
||||
"boto3==1.35.76",
|
||||
"Brotli==1.1.0",
|
||||
"celery[redis]==5.4.0",
|
||||
"django-configurations==2.5.1",
|
||||
"django-cors-headers==4.4.0",
|
||||
"django-cors-headers==4.6.0",
|
||||
"django-countries==7.6.1",
|
||||
"django-parler==2.3",
|
||||
"redis==5.0.8",
|
||||
"redis==5.2.1",
|
||||
"django-redis==5.4.0",
|
||||
"django-storages[s3]==1.14.4",
|
||||
"django-timezone-field>=5.1",
|
||||
"django==5.1.1",
|
||||
"django==5.1.4",
|
||||
"djangorestframework==3.15.2",
|
||||
"drf_spectacular==0.27.2",
|
||||
"drf_spectacular==0.28.0",
|
||||
"dockerflow==2024.4.2",
|
||||
"easy_thumbnails==2.10",
|
||||
"factory_boy==3.3.1",
|
||||
@@ -47,17 +47,17 @@ dependencies = [
|
||||
"june-analytics-python==2.3.0",
|
||||
"markdown==3.7",
|
||||
"nested-multipart-parser==1.5.0",
|
||||
"psycopg[binary]==3.2.2",
|
||||
"PyJWT==2.9.0",
|
||||
"psycopg[binary]==3.2.3",
|
||||
"PyJWT==2.10.1",
|
||||
"python-frontmatter==1.1.0",
|
||||
"requests==2.32.3",
|
||||
"sentry-sdk==2.14.0",
|
||||
"sentry-sdk==2.19.2",
|
||||
"url-normalize==1.4.3",
|
||||
"WeasyPrint>=60.2",
|
||||
"whitenoise==6.7.0",
|
||||
"whitenoise==6.8.2",
|
||||
"mozilla-django-oidc==4.0.1",
|
||||
"livekit-api==0.7.0",
|
||||
"aiohttp==3.10.10",
|
||||
"livekit-api==0.8.0",
|
||||
"aiohttp==3.11.10",
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
@@ -69,21 +69,21 @@ dependencies = [
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"django-extensions==3.2.3",
|
||||
"drf-spectacular-sidecar==2024.7.1",
|
||||
"drf-spectacular-sidecar==2024.12.1",
|
||||
"freezegun==1.5.1",
|
||||
"ipdb==0.13.13",
|
||||
"ipython==8.27.0",
|
||||
"pyfakefs==5.6.0",
|
||||
"pylint-django==2.5.5",
|
||||
"pylint==3.2.7",
|
||||
"pytest-cov==5.0.0",
|
||||
"ipython==8.30.0",
|
||||
"pyfakefs==5.7.2",
|
||||
"pylint-django==2.6.1",
|
||||
"pylint==3.3.2",
|
||||
"pytest-cov==6.0.0",
|
||||
"pytest-django==4.9.0",
|
||||
"pytest==8.3.3",
|
||||
"pytest==8.3.4",
|
||||
"pytest-icdiff==0.9",
|
||||
"pytest-xdist==3.6.1",
|
||||
"responses==0.25.3",
|
||||
"ruff==0.6.5",
|
||||
"types-requests==2.32.0.20240914",
|
||||
"ruff==0.8.2",
|
||||
"types-requests==2.32.0.20241016",
|
||||
]
|
||||
|
||||
[tool.setuptools]
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<html lang="en" data-lk-theme="visio-light">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/play-icon.svg" />
|
||||
<link rel="icon" type="image/svg+xml" href="/assets/icon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Visio</title>
|
||||
</head>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "meet",
|
||||
"private": true,
|
||||
"version": "0.1.8",
|
||||
"version": "0.1.12",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "panda codegen && vite",
|
||||
@@ -13,48 +13,48 @@
|
||||
"check": "prettier --check ./src"
|
||||
},
|
||||
"dependencies": {
|
||||
"@livekit/components-react": "2.6.5",
|
||||
"@livekit/components-styles": "1.1.3",
|
||||
"@livekit/components-react": "2.6.9",
|
||||
"@livekit/components-styles": "1.1.4",
|
||||
"@livekit/track-processors": "0.3.2",
|
||||
"@pandacss/preset-panda": "0.46.1",
|
||||
"@react-aria/toast": "3.0.0-beta.16",
|
||||
"@remixicon/react": "4.2.0",
|
||||
"@tanstack/react-query": "5.59.4",
|
||||
"@pandacss/preset-panda": "0.48.0",
|
||||
"@react-aria/toast": "3.0.0-beta.18",
|
||||
"@remixicon/react": "4.5.0",
|
||||
"@tanstack/react-query": "5.61.3",
|
||||
"crisp-sdk-web": "1.0.25",
|
||||
"hoofd": "1.7.1",
|
||||
"i18next": "23.15.2",
|
||||
"i18next": "24.0.2",
|
||||
"i18next-browser-languagedetector": "8.0.0",
|
||||
"i18next-parser": "9.0.2",
|
||||
"i18next-resources-to-backend": "1.2.1",
|
||||
"livekit-client": "2.5.7",
|
||||
"posthog-js": "1.186.1",
|
||||
"livekit-client": "2.6.3",
|
||||
"posthog-js": "1.188.0",
|
||||
"react": "18.3.1",
|
||||
"react-aria-components": "1.4.0",
|
||||
"react-aria-components": "1.5.0",
|
||||
"react-dom": "18.3.1",
|
||||
"react-i18next": "15.0.2",
|
||||
"react-i18next": "15.1.1",
|
||||
"use-sound": "4.0.3",
|
||||
"valtio": "2.0.0",
|
||||
"valtio": "2.1.2",
|
||||
"wouter": "3.3.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@pandacss/dev": "0.46.1",
|
||||
"@tanstack/eslint-plugin-query": "5.59.2",
|
||||
"@tanstack/react-query-devtools": "5.59.4",
|
||||
"@types/node": "20.16.11",
|
||||
"@types/react": "18.3.11",
|
||||
"@types/react-dom": "18.3.0",
|
||||
"@typescript-eslint/eslint-plugin": "8.8.1",
|
||||
"@typescript-eslint/parser": "8.8.1",
|
||||
"@vitejs/plugin-react": "4.3.2",
|
||||
"@pandacss/dev": "0.48.0",
|
||||
"@tanstack/eslint-plugin-query": "5.61.3",
|
||||
"@tanstack/react-query-devtools": "5.61.3",
|
||||
"@types/node": "22.9.3",
|
||||
"@types/react": "18.3.12",
|
||||
"@types/react-dom": "18.3.1",
|
||||
"@typescript-eslint/eslint-plugin": "8.15.0",
|
||||
"@typescript-eslint/parser": "8.15.0",
|
||||
"@vitejs/plugin-react": "4.3.3",
|
||||
"eslint": "8.57.0",
|
||||
"eslint-config-prettier": "9.1.0",
|
||||
"eslint-plugin-jsx-a11y": "6.10.0",
|
||||
"eslint-plugin-react-hooks": "4.6.2",
|
||||
"eslint-plugin-react-refresh": "0.4.12",
|
||||
"postcss": "8.4.47",
|
||||
"eslint-plugin-jsx-a11y": "6.10.2",
|
||||
"eslint-plugin-react-hooks": "5.0.0",
|
||||
"eslint-plugin-react-refresh": "0.4.14",
|
||||
"postcss": "8.4.49",
|
||||
"prettier": "3.3.3",
|
||||
"typescript": "5.6.3",
|
||||
"vite": "5.4.8",
|
||||
"vite-tsconfig-paths": "5.0.1"
|
||||
"typescript": "5.7.2",
|
||||
"vite": "5.4.11",
|
||||
"vite-tsconfig-paths": "5.1.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,6 +35,40 @@ 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',
|
||||
},
|
||||
],
|
||||
},
|
||||
theme: {
|
||||
...pandaPreset.theme,
|
||||
// media queries are defined in em so that zooming with text-only mode triggers breakpoints
|
||||
@@ -82,10 +116,10 @@ const config: Config = {
|
||||
'80%': { transform: 'rotate(20deg)' },
|
||||
'100%': { transform: 'rotate(0)' },
|
||||
},
|
||||
pulse_mic: {
|
||||
'0%': { color: 'primary', opacity: '1' },
|
||||
'50%': { color: 'primary', opacity: '0.8' },
|
||||
'100%': { color: 'primary', opacity: '1' },
|
||||
pulse_background: {
|
||||
'0%': { opacity: '1' },
|
||||
'50%': { opacity: '0.65' },
|
||||
'100%': { opacity: '1' },
|
||||
},
|
||||
},
|
||||
tokens: defineTokens({
|
||||
@@ -93,6 +127,66 @@ const config: Config = {
|
||||
* This way we'll only add the things we need step by step and prevent using lots of differents things.
|
||||
*/
|
||||
...pandaPreset.theme.tokens,
|
||||
colors: defineTokens.colors({
|
||||
...pandaPreset.theme.tokens.colors,
|
||||
primaryDark: {
|
||||
50: { value: '#161622' },
|
||||
100: { value: '#2D2D46' },
|
||||
200: { value: '#43436A' },
|
||||
300: { value: '#5A5A8F' },
|
||||
400: { value: '#7070B3' },
|
||||
500: { value: '#8787D7' },
|
||||
600: { value: '#9D9DDF' },
|
||||
700: { value: '#B3B3E7' },
|
||||
800: { value: '#C9C9EE' },
|
||||
900: { value: '#DFDFF6' },
|
||||
950: { value: '#F5F5FE' },
|
||||
action: { value: '#C1C1FB' },
|
||||
},
|
||||
primary: {
|
||||
50: { value: '#F5F5FE' },
|
||||
100: { value: '#ECECFE' },
|
||||
200: { value: '#E3E3FB' },
|
||||
300: { value: '#CACAFB' },
|
||||
400: { value: '#8585F6' },
|
||||
500: { value: '#6A6AF4' },
|
||||
600: { value: '#313178' },
|
||||
700: { value: '#272747' },
|
||||
800: { value: '#000091' },
|
||||
900: { value: '#21213F' },
|
||||
950: { value: '#1B1B35' },
|
||||
action: { value: '#1212FF' },
|
||||
},
|
||||
greyscale: {
|
||||
'000': { value: '#FFFFFF' },
|
||||
50: { value: '#F6F6F6' },
|
||||
100: { value: '#EEEEEE' },
|
||||
200: { value: '#E5E5E5' },
|
||||
250: { value: '#DDDDDD' },
|
||||
300: { value: '#CECECE' },
|
||||
400: { value: '#929292' },
|
||||
500: { value: '#7C7C7C' },
|
||||
600: { value: '#666666' },
|
||||
700: { value: '#3A3A3A' },
|
||||
750: { value: '#353535' },
|
||||
800: { value: '#2A2A2A' },
|
||||
900: { value: '#242424' },
|
||||
950: { value: '#1E1E1E' },
|
||||
1000: { value: '#161616' },
|
||||
},
|
||||
error: {
|
||||
100: { value: '#261212' },
|
||||
200: { value: '#6C302E' },
|
||||
300: { value: '#983533' },
|
||||
400: { value: '#CA3632' },
|
||||
500: { value: '#EF413D' },
|
||||
600: { value: '#EE6A66' },
|
||||
700: { value: '#F28D8A' },
|
||||
800: { value: '#F6AFAD' },
|
||||
900: { value: '#FAD2D1' },
|
||||
950: { value: '#FFF4F4' },
|
||||
},
|
||||
}),
|
||||
animations: {},
|
||||
blurs: {},
|
||||
/* just directly use values as tokens. This allows us to follow a specific design scale,
|
||||
@@ -194,7 +288,7 @@ const config: Config = {
|
||||
semanticTokens: defineSemanticTokens({
|
||||
colors: {
|
||||
default: {
|
||||
text: { value: '{colors.gray.900}' },
|
||||
text: { value: '{colors.greyscale.1000}' },
|
||||
bg: { value: 'white' },
|
||||
subtle: { value: '{colors.gray.100}' },
|
||||
'subtle-text': { value: '{colors.gray.600}' },
|
||||
|
||||
|
After Width: | Height: | Size: 13 KiB |
@@ -0,0 +1,5 @@
|
||||
<svg width="160" height="160" viewBox="0 0 160 160" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M5.61135 59.6951C5 62.832 5 66.6949 5 73.1656V88.7307C5 96.7588 5 100.773 6.16756 104.366C7.20062 107.546 8.89041 110.472 11.1274 112.957C13.6555 115.765 17.1318 117.772 24.0842 121.786L37.564 129.568C43.7169 133.121 47.1472 135.101 50.4165 136.054V88.0288C50.4165 85.6281 49.1052 83.4192 46.9976 82.2696L5.61135 59.6951Z" fill="#C9191E"/>
|
||||
<path d="M118.313 66.7489V91.7898C118.313 95.2521 119.818 98.5435 122.436 100.809L140.459 116.406C141.513 117.298 142.608 118.028 143.744 118.596C144.92 119.123 146.056 119.387 147.151 119.387C149.503 119.387 151.389 118.616 152.809 117.075C154.269 115.493 154.999 113.445 154.999 110.93V47.6577C154.999 45.1431 154.269 43.1151 152.809 41.5738C151.389 39.992 149.503 39.2011 147.151 39.2011C146.056 39.2011 144.92 39.4647 143.744 39.992C142.608 40.5193 141.513 41.2494 140.459 42.1822L122.45 57.7172C119.823 59.983 118.313 63.28 118.313 66.7489Z" fill="#000091"/>
|
||||
<path d="M11.6345 50.7522C11.1333 50.4788 10.6078 50.2937 10.0757 50.1915C10.4114 49.7628 10.7622 49.3452 11.1276 48.9394C13.6558 46.1315 17.132 44.1245 24.0845 40.1105L37.5643 32.3279C44.5167 28.3139 47.993 26.3069 51.6887 25.5213C54.9587 24.8262 58.3383 24.8262 61.6083 25.5213C65.304 26.3069 68.7803 28.3139 75.7327 32.3279L89.0535 40.0187C89.1066 40.0492 89.1597 40.0798 89.2129 40.1105C96.1653 44.1245 99.6416 46.1316 102.17 48.9394C104.407 51.4238 106.096 54.3506 107.13 57.5301C108.297 61.1235 108.297 65.1375 108.297 73.1656V88.7307C108.297 96.7588 108.297 100.773 107.13 104.366C106.096 107.546 104.407 110.473 102.17 112.957C99.6416 115.765 96.1655 117.772 89.2133 121.785C89.1537 121.82 89.0936 121.855 89.0341 121.889L75.7327 129.568C68.7803 133.582 65.304 135.589 61.6083 136.375C61.4564 136.407 61.3043 136.438 61.1519 136.467L61.1519 88.0288C61.1519 81.6997 57.6948 75.8761 52.1386 72.8455L11.6345 50.7522Z" fill="#000091"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.9 KiB |
@@ -0,0 +1,3 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M6.66667 4.00098V5.33431H3.33333V12.6676H10.6667V9.33431H12V13.3343C12 13.7025 11.7015 14.001 11.3333 14.001H2.66667C2.29848 14.001 2 13.7025 2 13.3343V4.66764C2 4.29945 2.29848 4.00098 2.66667 4.00098H6.66667ZM14 2.00098V7.33431H12.6667V4.27631L7.47133 9.47231L6.52867 8.52964L11.7233 3.33431H8.66667V2.00098H14Z" fill="#666666"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 484 B |
|
After Width: | Height: | Size: 21 KiB |
@@ -30,7 +30,7 @@ function App() {
|
||||
<Route component={NotFoundScreen} />
|
||||
</Switch>
|
||||
</Layout>
|
||||
<ReactQueryDevtools initialIsOpen={false} />
|
||||
<ReactQueryDevtools initialIsOpen={false} buttonPosition="top-left" />
|
||||
</I18nProvider>
|
||||
</Suspense>
|
||||
</QueryClientProvider>
|
||||
|
||||
@@ -1,12 +1,7 @@
|
||||
import { fetchApi } from './fetchApi'
|
||||
import { keys } from './queryKeys'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
|
||||
// todo - refactor it in a proper place
|
||||
export enum RecordingMode {
|
||||
Transcript = 'transcript',
|
||||
ScreenRecording = 'screen_recording',
|
||||
}
|
||||
import { RecordingMode } from '@/features/rooms/api/startRecording'
|
||||
|
||||
export interface ApiConfig {
|
||||
analytics?: {
|
||||
|
||||
|
Before Width: | Height: | Size: 635 KiB After Width: | Height: | Size: 651 KiB |
@@ -0,0 +1,6 @@
|
||||
<svg width="448" height="172" viewBox="0 0 448 172" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M5.61135 65.6951C5 68.832 5 72.6949 5 79.1656V94.7307C5 102.759 5 106.773 6.16756 110.366C7.20062 113.546 8.89041 116.472 11.1274 118.957C13.6555 121.765 17.1318 123.772 24.0842 127.786L37.564 135.568C43.7169 139.121 47.1472 141.101 50.4165 142.054V94.0288C50.4165 91.6281 49.1052 89.4192 46.9976 88.2696L5.61135 65.6951Z" fill="#C9191E"/>
|
||||
<path d="M118.313 72.7489V97.7898C118.313 101.252 119.818 104.544 122.436 106.809L140.459 122.406C141.513 123.298 142.608 124.028 143.744 124.596C144.92 125.123 146.056 125.387 147.151 125.387C149.503 125.387 151.389 124.616 152.809 123.075C154.269 121.493 154.999 119.445 154.999 116.93V53.6577C154.999 51.1431 154.269 49.1151 152.809 47.5738C151.389 45.992 149.503 45.2011 147.151 45.2011C146.056 45.2011 144.92 45.4647 143.744 45.992C142.608 46.5193 141.513 47.2494 140.459 48.1822L122.45 63.7172C119.823 65.983 118.313 69.28 118.313 72.7489Z" fill="#000091"/>
|
||||
<path d="M11.6345 56.7522C11.1333 56.4788 10.6078 56.2937 10.0757 56.1915C10.4114 55.7628 10.7622 55.3452 11.1276 54.9394C13.6558 52.1315 17.132 50.1245 24.0845 46.1105L37.5643 38.3279C44.5167 34.3139 47.993 32.3069 51.6887 31.5213C54.9587 30.8262 58.3383 30.8262 61.6083 31.5213C65.304 32.3069 68.7803 34.3139 75.7327 38.3279L89.0535 46.0187C89.1066 46.0492 89.1597 46.0798 89.2129 46.1105C96.1653 50.1245 99.6416 52.1316 102.17 54.9394C104.407 57.4238 106.096 60.3506 107.13 63.5301C108.297 67.1235 108.297 71.1375 108.297 79.1656V94.7307C108.297 102.759 108.297 106.773 107.13 110.366C106.096 113.546 104.407 116.473 102.17 118.957C99.6416 121.765 96.1655 123.772 89.2133 127.785C89.1537 127.82 89.0936 127.855 89.0341 127.889L75.7327 135.568C68.7803 139.582 65.304 141.589 61.6083 142.375C61.4564 142.407 61.3043 142.438 61.1519 142.467L61.1519 94.0288C61.1519 87.6997 57.6948 81.8761 52.1386 78.8455L11.6345 56.7522Z" fill="#000091"/>
|
||||
<path d="M193.72 45.7333H211.035L234.771 108.456L258.507 45.7333H275.821L245.435 126H224.107L193.72 45.7333ZM289.019 58.1173C283.859 58.1173 279.501 53.76 279.501 48.6C279.501 43.44 283.859 39.0827 289.019 39.0827C294.179 39.0827 298.421 43.44 298.421 48.6C298.421 53.76 294.179 58.1173 289.019 58.1173ZM281.68 126V68.208H296.243V126H281.68ZM303.29 117.629L312.922 108.915C316.477 113.387 320.72 116.597 326.224 116.597C330.925 116.597 333.333 113.845 333.333 110.405C333.333 100.315 305.698 104.099 305.698 83.8027C305.698 73.5973 314.298 65.9147 326.338 65.9147C335.168 65.9147 343.194 70.1573 347.322 75.776L337.69 84.2613C334.709 80.592 330.925 77.6107 326.453 77.6107C321.866 77.6107 319.688 80.1333 319.688 83.1147C319.688 92.976 347.322 89.536 347.322 109.488C347.093 121.643 337.346 128.293 326.453 128.293C316.133 128.293 308.794 124.165 303.29 117.629ZM363.494 58.1173C358.334 58.1173 353.977 53.76 353.977 48.6C353.977 43.44 358.334 39.0827 363.494 39.0827C368.654 39.0827 372.897 43.44 372.897 48.6C372.897 53.76 368.654 58.1173 363.494 58.1173ZM356.155 126V68.208H370.718V126H356.155ZM411.115 65.9147C429.92 65.9147 442.763 79.7893 442.763 97.104C442.763 114.419 429.92 128.293 411.115 128.293C392.309 128.293 379.467 114.419 379.467 97.104C379.467 79.7893 392.309 65.9147 411.115 65.9147ZM411.344 114.533C420.632 114.533 427.627 107.08 427.627 97.104C427.627 87.0133 420.632 79.6747 411.344 79.6747C401.712 79.6747 394.603 87.0133 394.603 97.104C394.603 107.195 401.712 114.533 411.344 114.533Z" fill="#000091"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.4 KiB |
@@ -2,9 +2,7 @@ import { css } from '@/styled-system/css'
|
||||
import { RiErrorWarningLine, RiExternalLinkLine } from '@remixicon/react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Text, A } from '@/primitives'
|
||||
|
||||
const GRIST_FORM =
|
||||
'https://grist.numerique.gouv.fr/o/docs/forms/1YrfNP1QSSy8p2gCxMFnSf/4'
|
||||
import { GRIST_FORM } from '@/utils/constants'
|
||||
|
||||
export const FeedbackBanner = () => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
@@ -7,6 +7,7 @@ import { Center } from '@/styled-system/jsx'
|
||||
export const LoadingScreen = ({
|
||||
delay = 500,
|
||||
header = undefined,
|
||||
footer = undefined,
|
||||
layout = 'centered',
|
||||
}: {
|
||||
delay?: number
|
||||
@@ -15,7 +16,7 @@ export const LoadingScreen = ({
|
||||
|
||||
return (
|
||||
<DelayedRender delay={delay}>
|
||||
<Screen layout={layout} header={header}>
|
||||
<Screen layout={layout} header={header} footer={footer}>
|
||||
<CenteredContent>
|
||||
<Center>
|
||||
<p>{t('loading')}</p>
|
||||
|
||||
@@ -21,7 +21,7 @@ export const QueryAware = ({
|
||||
}
|
||||
|
||||
if (status === 'pending') {
|
||||
return <LoadingScreen header={undefined} />
|
||||
return <LoadingScreen header={undefined} footer={undefined} />
|
||||
}
|
||||
|
||||
return children
|
||||
|
||||
@@ -27,7 +27,7 @@ export const SoundTester = () => {
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
invisible
|
||||
variant="secondaryText"
|
||||
onPress={() => {
|
||||
audioRef?.current?.play()
|
||||
setIsPlaying(true)
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
import { useConfig } from '@/api/useConfig.ts'
|
||||
|
||||
export const useIsAnalyticsEnabled = () => {
|
||||
const { data } = useConfig()
|
||||
return !!data?.analytics?.id
|
||||
}
|
||||
@@ -15,6 +15,6 @@ export const UserAware = ({ children }: { children: React.ReactNode }) => {
|
||||
return isLoggedIn !== undefined ? (
|
||||
children
|
||||
) : (
|
||||
<LoadingScreen header={false} delay={1000} />
|
||||
<LoadingScreen header={false} footer={false} delay={1000} />
|
||||
)
|
||||
}
|
||||
|
||||
@@ -185,8 +185,8 @@ export const IntroSlider = () => {
|
||||
<ButtonContainer>
|
||||
<ButtonVerticalCenter>
|
||||
<Button
|
||||
variant="secondaryText"
|
||||
square
|
||||
invisible
|
||||
aria-label={t('previous.label')}
|
||||
tooltip={t('previous.tooltip')}
|
||||
onPress={() => setSlideIndex(slideIndex - 1)}
|
||||
@@ -221,8 +221,8 @@ export const IntroSlider = () => {
|
||||
<ButtonContainer>
|
||||
<ButtonVerticalCenter>
|
||||
<Button
|
||||
variant="secondaryText"
|
||||
square
|
||||
invisible
|
||||
aria-label={t('next.label')}
|
||||
tooltip={t('next.tooltip')}
|
||||
onPress={() => setSlideIndex(slideIndex + 1)}
|
||||
|
||||
@@ -10,7 +10,6 @@ import { JoinMeetingDialog } from '../components/JoinMeetingDialog'
|
||||
import { ProConnectButton } from '@/components/ProConnectButton'
|
||||
import { useCreateRoom } from '@/features/rooms'
|
||||
import { usePersistentUserChoices } from '@livekit/components-react'
|
||||
import { menuItemRecipe } from '@/primitives/menuItemRecipe'
|
||||
import { RiAddLine, RiLink } from '@remixicon/react'
|
||||
import { LaterMeetingDialog } from '@/features/home/components/LaterMeetingDialog'
|
||||
import { IntroSlider } from '@/features/home/components/IntroSlider'
|
||||
@@ -18,6 +17,7 @@ import { MoreLink } from '@/features/home/components/MoreLink'
|
||||
import { ReactNode, useState } from 'react'
|
||||
|
||||
import { css } from '@/styled-system/css'
|
||||
import { menuRecipe } from '@/primitives/menuRecipe.ts'
|
||||
|
||||
const Columns = ({ children }: { children?: ReactNode }) => {
|
||||
return (
|
||||
@@ -32,8 +32,13 @@ const Columns = ({ children }: { children?: ReactNode }) => {
|
||||
justifyContent: 'normal',
|
||||
padding: '0 1rem',
|
||||
width: 'calc(100% - 2rem)',
|
||||
opacity: 0,
|
||||
animation: '.5s ease-in fade 0s forwards',
|
||||
'@media(prefers-reduced-motion: reduce)': {
|
||||
opacity: 1,
|
||||
},
|
||||
'@media(prefers-reduced-motion: no-preference)': {
|
||||
opacity: 0,
|
||||
animation: '.5s ease-in fade 0s forwards',
|
||||
},
|
||||
lg: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'center',
|
||||
@@ -173,7 +178,9 @@ export const Home = () => {
|
||||
</Button>
|
||||
<RACMenu>
|
||||
<MenuItem
|
||||
className={menuItemRecipe({ icon: true })}
|
||||
className={
|
||||
menuRecipe({ icon: true, variant: 'light' }).item
|
||||
}
|
||||
onAction={async () => {
|
||||
const slug = generateRoomId()
|
||||
createRoom({ slug, username }).then((data) =>
|
||||
@@ -188,7 +195,9 @@ export const Home = () => {
|
||||
{t('createMenu.instantOption')}
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
className={menuItemRecipe({ icon: true })}
|
||||
className={
|
||||
menuRecipe({ icon: true, variant: 'light' }).item
|
||||
}
|
||||
onAction={() => {
|
||||
const slug = generateRoomId()
|
||||
createRoom({ slug, username }).then((data) =>
|
||||
@@ -207,8 +216,7 @@ export const Home = () => {
|
||||
)}
|
||||
<DialogTrigger>
|
||||
<Button
|
||||
variant="primary"
|
||||
outline
|
||||
variant="secondary"
|
||||
style={{
|
||||
height: !isLoggedIn ? '56px' : undefined, // Temporary, Align with ProConnect Button fixed height
|
||||
}}
|
||||
|
||||
@@ -16,11 +16,11 @@ export const MainNotificationToast = () => {
|
||||
if (isMobileBrowser()) {
|
||||
return
|
||||
}
|
||||
triggerNotificationSound(NotificationType.Joined)
|
||||
triggerNotificationSound(NotificationType.ParticipantJoined)
|
||||
toastQueue.add(
|
||||
{
|
||||
participant,
|
||||
type: NotificationType.Joined,
|
||||
type: NotificationType.ParticipantJoined,
|
||||
},
|
||||
{
|
||||
timeout: 5000,
|
||||
@@ -67,7 +67,7 @@ export const MainNotificationToast = () => {
|
||||
const existingToast = toastQueue.visibleToasts.find(
|
||||
(toast) =>
|
||||
toast.content.participant === participant &&
|
||||
toast.content.type === NotificationType.Raised
|
||||
toast.content.type === NotificationType.HandRaised
|
||||
)
|
||||
|
||||
if (existingToast && prevMetadata.raised && !metadata.raised) {
|
||||
@@ -76,11 +76,11 @@ export const MainNotificationToast = () => {
|
||||
}
|
||||
|
||||
if (!existingToast && !prevMetadata.raised && metadata.raised) {
|
||||
triggerNotificationSound(NotificationType.Raised)
|
||||
triggerNotificationSound(NotificationType.HandRaised)
|
||||
toastQueue.add(
|
||||
{
|
||||
participant,
|
||||
type: NotificationType.Raised,
|
||||
type: NotificationType.HandRaised,
|
||||
},
|
||||
{ timeout: 5000 }
|
||||
)
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
export enum NotificationType {
|
||||
Joined = 'joined',
|
||||
Default = 'default',
|
||||
Raised = 'raised',
|
||||
Lowered = 'lowered',
|
||||
ParticipantJoined = 'participantJoined',
|
||||
HandRaised = 'handRaised',
|
||||
// todo - implement message received notification
|
||||
}
|
||||
|
||||
@@ -17,10 +17,10 @@ export function ToastRegion({ state, ...props }: ToastRegionProps) {
|
||||
return (
|
||||
<div {...regionProps} ref={ref} className="toast-region">
|
||||
{state.visibleToasts.map((toast) => {
|
||||
if (toast.content?.type === NotificationType.Joined) {
|
||||
if (toast.content?.type === NotificationType.ParticipantJoined) {
|
||||
return <ToastJoined key={toast.key} toast={toast} state={state} />
|
||||
}
|
||||
if (toast.content?.type === NotificationType.Raised) {
|
||||
if (toast.content?.type === NotificationType.HandRaised) {
|
||||
return <ToastRaised key={toast.key} toast={toast} state={state} />
|
||||
}
|
||||
return <Toast key={toast.key} toast={toast} state={state} />
|
||||
|
||||
@@ -1,18 +1,24 @@
|
||||
import useSound from 'use-sound'
|
||||
import { useSnapshot } from 'valtio'
|
||||
import { notificationsStore } from '@/stores/notifications'
|
||||
import { NotificationType } from '@/features/notifications/NotificationType'
|
||||
|
||||
// fixme - handle dynamic audio output changes
|
||||
export const useNotificationSound = () => {
|
||||
const notificationsSnap = useSnapshot(notificationsStore)
|
||||
const [play] = useSound('./sounds/notifications.mp3', {
|
||||
sprite: {
|
||||
joined: [0, 1150],
|
||||
raised: [1400, 180],
|
||||
message: [1580, 300],
|
||||
participantJoined: [0, 1150],
|
||||
handRaised: [1400, 180],
|
||||
messageReceived: [1580, 300],
|
||||
waiting: [2039, 710],
|
||||
success: [2740, 1304],
|
||||
},
|
||||
volume: notificationsSnap.soundNotificationVolume,
|
||||
})
|
||||
const triggerNotificationSound = (type: string) => {
|
||||
play({ id: type })
|
||||
const triggerNotificationSound = (type: NotificationType) => {
|
||||
const isSoundEnabled = notificationsSnap.soundNotifications.get(type)
|
||||
if (isSoundEnabled) play({ id: type })
|
||||
}
|
||||
return { triggerNotificationSound }
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ export type ApiRoom = {
|
||||
name: string
|
||||
slug: string
|
||||
is_public: boolean
|
||||
is_administrable: boolean
|
||||
livekit?: {
|
||||
url: string
|
||||
room: string
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { useMutation, UseMutationOptions } from '@tanstack/react-query'
|
||||
import { fetchApi } from '@/api/fetchApi'
|
||||
import { ApiError } from '@/api/ApiError'
|
||||
import { ApiRoom } from './ApiRoom'
|
||||
|
||||
export enum RecordingMode {
|
||||
Transcript = 'transcript',
|
||||
ScreenRecording = 'screen_recording',
|
||||
}
|
||||
|
||||
export interface StartRecordingParams {
|
||||
id: string
|
||||
mode?: RecordingMode
|
||||
}
|
||||
|
||||
const startRecording = ({
|
||||
id,
|
||||
mode = RecordingMode.Transcript,
|
||||
}: StartRecordingParams): Promise<ApiRoom> => {
|
||||
return fetchApi(`rooms/${id}/start-recording/`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
mode: mode,
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
export function useStartRecording(
|
||||
options?: UseMutationOptions<ApiRoom, ApiError, StartRecordingParams>
|
||||
) {
|
||||
return useMutation<ApiRoom, ApiError, StartRecordingParams>({
|
||||
mutationFn: startRecording,
|
||||
onSuccess: options?.onSuccess,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { useMutation, UseMutationOptions } from '@tanstack/react-query'
|
||||
import { fetchApi } from '@/api/fetchApi'
|
||||
import { ApiError } from '@/api/ApiError'
|
||||
import { ApiRoom } from './ApiRoom'
|
||||
|
||||
export interface StopRecordingParams {
|
||||
id: string
|
||||
}
|
||||
|
||||
const stopRecording = ({ id }: StopRecordingParams): Promise<ApiRoom> => {
|
||||
return fetchApi(`rooms/${id}/stop-recording/`, {
|
||||
method: 'POST',
|
||||
})
|
||||
}
|
||||
|
||||
export function useStopRecording(
|
||||
options?: UseMutationOptions<ApiRoom, ApiError, StopRecordingParams>
|
||||
) {
|
||||
return useMutation<ApiRoom, ApiError, StopRecordingParams>({
|
||||
mutationFn: stopRecording,
|
||||
onSuccess: options?.onSuccess,
|
||||
})
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import { InviteDialog } from './InviteDialog'
|
||||
|
||||
import { VideoConference } from '../livekit/prefabs/VideoConference'
|
||||
import posthog from 'posthog-js'
|
||||
import { css } from '@/styled-system/css'
|
||||
|
||||
export const Conference = ({
|
||||
roomId,
|
||||
@@ -64,6 +65,11 @@ export const Conference = ({
|
||||
|
||||
const roomOptions = useMemo((): RoomOptions => {
|
||||
return {
|
||||
adaptiveStream: true,
|
||||
dynacast: true,
|
||||
publishDefaults: {
|
||||
videoCodec: 'vp9',
|
||||
},
|
||||
videoCaptureDefaults: {
|
||||
deviceId: userConfig.videoDeviceId ?? undefined,
|
||||
},
|
||||
@@ -98,7 +104,7 @@ export const Conference = ({
|
||||
|
||||
return (
|
||||
<QueryAware status={isFetchError ? createStatus : fetchStatus}>
|
||||
<Screen header={false}>
|
||||
<Screen header={false} footer={false}>
|
||||
<LiveKitRoom
|
||||
room={room}
|
||||
serverUrl={data?.livekit?.url}
|
||||
@@ -107,6 +113,9 @@ export const Conference = ({
|
||||
audio={userConfig.audioEnabled}
|
||||
video={userConfig.videoEnabled}
|
||||
connectOptions={connectOptions}
|
||||
className={css({
|
||||
backgroundColor: 'primaryDark.50 !important',
|
||||
})}
|
||||
>
|
||||
<VideoConference />
|
||||
{showInviteDialog && (
|
||||
|
||||
@@ -49,8 +49,6 @@ export const InviteDialog = ({
|
||||
}
|
||||
}, [isCopied])
|
||||
|
||||
const [isHovered, setIsHovered] = useState(false)
|
||||
|
||||
return (
|
||||
<StyledRACDialog {...dialogProps}>
|
||||
{({ close }) => (
|
||||
@@ -66,6 +64,7 @@ export const InviteDialog = ({
|
||||
<Div position="absolute" top="5" right="5">
|
||||
<Button
|
||||
invisible
|
||||
variant="tertiaryText"
|
||||
size="xs"
|
||||
onPress={() => {
|
||||
dialogProps.onClose?.()
|
||||
@@ -78,45 +77,24 @@ export const InviteDialog = ({
|
||||
</Div>
|
||||
<P>{t('shareDialog.description')}</P>
|
||||
<Button
|
||||
variant={isCopied ? 'success' : 'primary'}
|
||||
size="sm"
|
||||
variant={isCopied ? 'success' : 'tertiary'}
|
||||
fullWidth
|
||||
aria-label={t('shareDialog.copy')}
|
||||
style={{
|
||||
justifyContent: 'start',
|
||||
}}
|
||||
onPress={() => {
|
||||
navigator.clipboard.writeText(roomUrl)
|
||||
setIsCopied(true)
|
||||
}}
|
||||
onHoverChange={setIsHovered}
|
||||
data-attr="share-dialog-copy"
|
||||
>
|
||||
{isCopied ? (
|
||||
<>
|
||||
<RiCheckLine size={18} style={{ marginRight: '8px' }} />
|
||||
<RiCheckLine size={24} style={{ marginRight: '8px' }} />
|
||||
{t('shareDialog.copied')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<RiFileCopyLine
|
||||
size={18}
|
||||
style={{ marginRight: '8px', minWidth: '18px' }}
|
||||
/>
|
||||
{isHovered ? (
|
||||
t('shareDialog.copy')
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
textOverflow: 'ellipsis',
|
||||
overflow: 'hidden',
|
||||
userSelect: 'none',
|
||||
textWrap: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{roomUrl.replace(/^https?:\/\//, '')}
|
||||
</div>
|
||||
)}
|
||||
<RiFileCopyLine size={24} style={{ marginRight: '8px' }} />
|
||||
{t('shareDialog.copyButton')}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
@@ -13,7 +13,7 @@ export const Join = ({
|
||||
const { user } = useUser()
|
||||
|
||||
return (
|
||||
<Screen layout="centered">
|
||||
<Screen layout="centered" footer={false}>
|
||||
<CenteredContent title={t('join.heading')}>
|
||||
<PreJoin
|
||||
persistUserChoices
|
||||
|
||||
@@ -43,7 +43,7 @@ const ratingButtonRecipe = cva({
|
||||
variants: {
|
||||
selected: {
|
||||
true: {
|
||||
backgroundColor: '#1d4ed8',
|
||||
backgroundColor: 'primary.800',
|
||||
color: 'white',
|
||||
},
|
||||
false: {
|
||||
@@ -121,7 +121,13 @@ const OpenFeedback = ({
|
||||
>
|
||||
{t('submit')}
|
||||
</Button>
|
||||
<Button invisible size="sm" fullWidth onPress={onNext}>
|
||||
<Button
|
||||
invisible
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
fullWidth
|
||||
onPress={onNext}
|
||||
>
|
||||
{t('skip')}
|
||||
</Button>
|
||||
</VStack>
|
||||
|
||||
@@ -151,7 +151,6 @@ export const Effects = () => {
|
||||
<HStack>
|
||||
<ToggleButton
|
||||
size={'sm'}
|
||||
legacyStyle
|
||||
aria-label={tooltipLabel(BlurRadius.LIGHT)}
|
||||
tooltip={tooltipLabel(BlurRadius.LIGHT)}
|
||||
isDisabled={processorPending}
|
||||
@@ -162,7 +161,6 @@ export const Effects = () => {
|
||||
</ToggleButton>
|
||||
<ToggleButton
|
||||
size={'sm'}
|
||||
legacyStyle
|
||||
aria-label={tooltipLabel(BlurRadius.NORMAL)}
|
||||
tooltip={tooltipLabel(BlurRadius.NORMAL)}
|
||||
isDisabled={processorPending}
|
||||
|
||||
@@ -10,7 +10,7 @@ const StyledParticipantPlaceHolder = styled('div', {
|
||||
base: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
backgroundColor: '#3d4043', // fixme - copied from gmeet
|
||||
backgroundColor: 'primaryDark.100',
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { css } from '@/styled-system/css'
|
||||
import { RiRecordCircleLine } from '@remixicon/react'
|
||||
import { Text } from '@/primitives'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useRoomContext } from '@livekit/components-react'
|
||||
|
||||
export const RecordingStateToast = () => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'recording' })
|
||||
|
||||
const room = useRoomContext()
|
||||
|
||||
if (!room?.isRecording) return
|
||||
|
||||
return (
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
position: 'fixed',
|
||||
top: '10px',
|
||||
left: '10px',
|
||||
paddingY: '0.25rem',
|
||||
paddingX: '0.25rem 0.35rem',
|
||||
backgroundColor: 'primaryDark.200',
|
||||
borderColor: 'primaryDark.400',
|
||||
border: '1px solid',
|
||||
color: 'white',
|
||||
borderRadius: '4px',
|
||||
gap: '0.5rem',
|
||||
})}
|
||||
>
|
||||
<RiRecordCircleLine
|
||||
size={20}
|
||||
className={css({
|
||||
color: 'white',
|
||||
backgroundColor: 'danger.700',
|
||||
padding: '3px',
|
||||
borderRadius: '3px',
|
||||
})}
|
||||
/>
|
||||
<Text variant={'sm'}>{t('label')}</Text>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import { useSidePanel } from '../hooks/useSidePanel'
|
||||
import { ReactNode } from 'react'
|
||||
import { Effects } from './Effects'
|
||||
import { Chat } from '../prefabs/Chat'
|
||||
import { Transcript } from './Transcript'
|
||||
|
||||
type StyledSidePanelProps = {
|
||||
title: string
|
||||
@@ -68,6 +69,7 @@ const StyledSidePanel = ({
|
||||
>
|
||||
<Button
|
||||
invisible
|
||||
variant="tertiaryText"
|
||||
size="xs"
|
||||
onPress={onClose}
|
||||
aria-label={closeButtonTooltip}
|
||||
@@ -105,6 +107,7 @@ export const SidePanel = () => {
|
||||
isEffectsOpen,
|
||||
isChatOpen,
|
||||
isSidePanelOpen,
|
||||
isTranscriptOpen,
|
||||
} = useSidePanel()
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'sidePanel' })
|
||||
|
||||
@@ -126,6 +129,9 @@ export const SidePanel = () => {
|
||||
<Panel isOpen={isChatOpen}>
|
||||
<Chat />
|
||||
</Panel>
|
||||
<Panel isOpen={isTranscriptOpen}>
|
||||
<Transcript />
|
||||
</Panel>
|
||||
</StyledSidePanel>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import { Button, Div, H, 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 {
|
||||
RecordingMode,
|
||||
useStartRecording,
|
||||
} from '@/features/rooms/api/startRecording'
|
||||
import { useStopRecording } from '@/features/rooms/api/stopRecording'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { RoomEvent } from 'livekit-client'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
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 room = useRoomContext()
|
||||
|
||||
useEffect(() => {
|
||||
const handleRecordingStatusChanged = () => {
|
||||
setIsLoading(false)
|
||||
}
|
||||
room.on(RoomEvent.RecordingStatusChanged, handleRecordingStatusChanged)
|
||||
return () => {
|
||||
room.off(RoomEvent.RecordingStatusChanged, handleRecordingStatusChanged)
|
||||
}
|
||||
}, [room])
|
||||
|
||||
const handleTranscript = async () => {
|
||||
if (!roomId) {
|
||||
console.warn('No room ID found')
|
||||
return
|
||||
}
|
||||
try {
|
||||
setIsLoading(true)
|
||||
if (room.isRecording) {
|
||||
await stopRecordingRoom({ id: roomId })
|
||||
} else {
|
||||
await startRecordingRoom({ id: roomId, mode: RecordingMode.Transcript })
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to handle transcript:', error)
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasTranscriptAccess) return
|
||||
|
||||
return (
|
||||
<Div
|
||||
display="flex"
|
||||
overflowY="scroll"
|
||||
padding="0 1.5rem"
|
||||
flexGrow={1}
|
||||
flexDirection="column"
|
||||
alignItems="center"
|
||||
>
|
||||
<img src={thirdSlide} alt={'wip'} />
|
||||
{room.isRecording ? (
|
||||
<>
|
||||
<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()}>
|
||||
<RiStopCircleLine style={{ marginRight: '0.5rem' }} />{' '}
|
||||
{t('stop.button')}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<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()}>
|
||||
<RiRecordCircleLine style={{ marginRight: '0.5rem' }} />{' '}
|
||||
{t('start.button')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</Div>
|
||||
)
|
||||
}
|
||||
@@ -107,6 +107,7 @@ export const ChatInput = ({
|
||||
<Button
|
||||
square
|
||||
invisible
|
||||
variant="tertiaryText"
|
||||
size="sm"
|
||||
onPress={handleSubmit}
|
||||
isDisabled={isDisabled}
|
||||
|
||||
@@ -5,8 +5,12 @@ import { css } from '@/styled-system/css'
|
||||
import { ToggleButton } from '@/primitives'
|
||||
import { chatStore } from '@/stores/chat'
|
||||
import { useSidePanel } from '../../hooks/useSidePanel'
|
||||
import { ToggleButtonProps } from '@/primitives/ToggleButton'
|
||||
|
||||
export const ChatToggle = () => {
|
||||
export const ChatToggle = ({
|
||||
onPress,
|
||||
...props
|
||||
}: Partial<ToggleButtonProps>) => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'controls.chat' })
|
||||
|
||||
const chatSnap = useSnapshot(chatStore)
|
||||
@@ -23,12 +27,16 @@ export const ChatToggle = () => {
|
||||
>
|
||||
<ToggleButton
|
||||
square
|
||||
legacyStyle
|
||||
variant="primaryTextDark"
|
||||
aria-label={t(tooltipLabel)}
|
||||
tooltip={t(tooltipLabel)}
|
||||
isSelected={isChatOpen}
|
||||
onPress={() => toggleChat()}
|
||||
onPress={(e) => {
|
||||
toggleChat()
|
||||
onPress?.(e)
|
||||
}}
|
||||
data-attr={`controls-chat-${tooltipLabel}`}
|
||||
{...props}
|
||||
>
|
||||
<RiChat1Line />
|
||||
</ToggleButton>
|
||||
|
||||
@@ -24,7 +24,7 @@ export const HandToggle = () => {
|
||||
>
|
||||
<ToggleButton
|
||||
square
|
||||
legacyStyle
|
||||
variant="primaryDark"
|
||||
aria-label={t(tooltipLabel)}
|
||||
tooltip={t(tooltipLabel)}
|
||||
isSelected={isHandRaised}
|
||||
|
||||
@@ -1,33 +1,24 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { RiMore2Line } from '@remixicon/react'
|
||||
import { Button, Menu } from '@/primitives'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { OptionsMenuItems } from '@/features/rooms/livekit/components/controls/Options/OptionsMenuItems'
|
||||
import { SettingsDialogExtended } from '@/features/settings/components/SettingsDialogExtended'
|
||||
|
||||
export type DialogState = 'username' | 'settings' | null
|
||||
|
||||
export const OptionsButton = () => {
|
||||
const { t } = useTranslation('rooms')
|
||||
const [dialogOpen, setDialogOpen] = useState<DialogState>(null)
|
||||
|
||||
return (
|
||||
<>
|
||||
<Menu>
|
||||
<Button
|
||||
square
|
||||
legacyStyle
|
||||
variant="primaryDark"
|
||||
aria-label={t('options.buttonLabel')}
|
||||
tooltip={t('options.buttonLabel')}
|
||||
>
|
||||
<RiMore2Line />
|
||||
</Button>
|
||||
<OptionsMenuItems onOpenDialog={setDialogOpen} />
|
||||
<OptionsMenuItems />
|
||||
</Menu>
|
||||
<SettingsDialogExtended
|
||||
isOpen={dialogOpen === 'settings'}
|
||||
onOpenChange={(v) => !v && setDialogOpen(null)}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { menuItemRecipe } from '@/primitives/menuItemRecipe'
|
||||
import {
|
||||
RiAccountBoxLine,
|
||||
RiMegaphoneLine,
|
||||
@@ -6,19 +5,17 @@ import {
|
||||
} from '@remixicon/react'
|
||||
import { MenuItem, Menu as RACMenu, Section } from 'react-aria-components'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Dispatch, SetStateAction } from 'react'
|
||||
import { DialogState } from './OptionsButton'
|
||||
import { Separator } from '@/primitives/Separator'
|
||||
import { useSidePanel } from '../../../hooks/useSidePanel'
|
||||
import { menuRecipe } from '@/primitives/menuRecipe.ts'
|
||||
import { useSettingsDialog } from '../SettingsDialogContext'
|
||||
import { GRIST_FORM } from '@/utils/constants'
|
||||
|
||||
// @todo try refactoring it to use MenuList component
|
||||
export const OptionsMenuItems = ({
|
||||
onOpenDialog,
|
||||
}: {
|
||||
onOpenDialog: Dispatch<SetStateAction<DialogState>>
|
||||
}) => {
|
||||
export const OptionsMenuItems = () => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'options.items' })
|
||||
const { toggleEffects } = useSidePanel()
|
||||
const { setDialogOpen } = useSettingsDialog()
|
||||
return (
|
||||
<RACMenu
|
||||
style={{
|
||||
@@ -29,7 +26,7 @@ export const OptionsMenuItems = ({
|
||||
<Section>
|
||||
<MenuItem
|
||||
onAction={() => toggleEffects()}
|
||||
className={menuItemRecipe({ icon: true })}
|
||||
className={menuRecipe({ icon: true }).item}
|
||||
>
|
||||
<RiAccountBoxLine size={20} />
|
||||
{t('effects')}
|
||||
@@ -38,16 +35,16 @@ export const OptionsMenuItems = ({
|
||||
<Separator />
|
||||
<Section>
|
||||
<MenuItem
|
||||
href="https://grist.incubateur.net/o/docs/forms/1YrfNP1QSSy8p2gCxMFnSf/4"
|
||||
href={GRIST_FORM}
|
||||
target="_blank"
|
||||
className={menuItemRecipe({ icon: true })}
|
||||
className={menuRecipe({ icon: true }).item}
|
||||
>
|
||||
<RiMegaphoneLine size={20} />
|
||||
{t('feedbacks')}
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
className={menuItemRecipe({ icon: true })}
|
||||
onAction={() => onOpenDialog('settings')}
|
||||
className={menuRecipe({ icon: true }).item}
|
||||
onAction={() => setDialogOpen(true)}
|
||||
>
|
||||
<RiSettings3Line size={20} />
|
||||
{t('settings')}
|
||||
|
||||
@@ -69,7 +69,7 @@ export const HandRaisedListItem = ({
|
||||
</HStack>
|
||||
<Button
|
||||
square
|
||||
invisible
|
||||
variant="greyscale"
|
||||
size="sm"
|
||||
onPress={() => lowerHandParticipant(participant)}
|
||||
tooltip={t('participants.lowerParticipantHand', { name })}
|
||||
|
||||
@@ -63,7 +63,7 @@ const MicIndicator = ({ participant }: MicIndicatorProps) => {
|
||||
<>
|
||||
<Button
|
||||
square
|
||||
invisible
|
||||
variant="greyscale"
|
||||
size="sm"
|
||||
tooltip={
|
||||
isLocal(participant)
|
||||
@@ -84,9 +84,12 @@ const MicIndicator = ({ participant }: MicIndicatorProps) => {
|
||||
<RiMicOffFill color={'gray'} />
|
||||
) : (
|
||||
<RiMicFill
|
||||
style={{
|
||||
animation: isSpeaking ? 'pulse_mic 800ms infinite' : undefined,
|
||||
}}
|
||||
className={css({
|
||||
color: isSpeaking ? 'primaryDark.300' : 'primaryDark.50',
|
||||
animation: isSpeaking
|
||||
? 'pulse_background 800ms infinite'
|
||||
: undefined,
|
||||
})}
|
||||
/>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
@@ -4,8 +4,12 @@ import { ToggleButton } from '@/primitives'
|
||||
import { css } from '@/styled-system/css'
|
||||
import { useParticipants } from '@livekit/components-react'
|
||||
import { useSidePanel } from '../../../hooks/useSidePanel'
|
||||
import { ToggleButtonProps } from '@/primitives/ToggleButton'
|
||||
|
||||
export const ParticipantsToggle = () => {
|
||||
export const ParticipantsToggle = ({
|
||||
onPress,
|
||||
...props
|
||||
}: ToggleButtonProps) => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'controls.participants' })
|
||||
|
||||
/**
|
||||
@@ -29,12 +33,16 @@ export const ParticipantsToggle = () => {
|
||||
>
|
||||
<ToggleButton
|
||||
square
|
||||
legacyStyle
|
||||
variant="primaryTextDark"
|
||||
aria-label={t(tooltipLabel)}
|
||||
tooltip={t(tooltipLabel)}
|
||||
isSelected={isParticipantsOpen}
|
||||
onPress={() => toggleParticipants()}
|
||||
onPress={(e) => {
|
||||
toggleParticipants()
|
||||
onPress?.(e)
|
||||
}}
|
||||
data-attr={`controls-participants-${tooltipLabel}`}
|
||||
{...props}
|
||||
>
|
||||
<RiGroupLine />
|
||||
</ToggleButton>
|
||||
|
||||
@@ -4,13 +4,21 @@ import { useTranslation } from 'react-i18next'
|
||||
import { useTrackToggle, UseTrackToggleProps } from '@livekit/components-react'
|
||||
import { Track } from 'livekit-client'
|
||||
import React from 'react'
|
||||
import { type ButtonRecipeProps } from '@/primitives/buttonRecipe'
|
||||
import { ToggleButtonProps } from '@/primitives/ToggleButton'
|
||||
|
||||
export const ScreenShareToggle = (
|
||||
props: Omit<
|
||||
UseTrackToggleProps<Track.Source.ScreenShare>,
|
||||
'source' | 'captureOptions'
|
||||
>
|
||||
) => {
|
||||
type Props = Omit<
|
||||
UseTrackToggleProps<Track.Source.ScreenShare>,
|
||||
'source' | 'captureOptions'
|
||||
> &
|
||||
Pick<NonNullable<ButtonRecipeProps>, 'variant'> &
|
||||
ToggleButtonProps
|
||||
|
||||
export const ScreenShareToggle = ({
|
||||
variant = 'primaryDark',
|
||||
onPress,
|
||||
...props
|
||||
}: Props) => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'controls.screenShare' })
|
||||
const { buttonProps, enabled } = useTrackToggle({
|
||||
...props,
|
||||
@@ -26,18 +34,16 @@ export const ScreenShareToggle = (
|
||||
<ToggleButton
|
||||
isSelected={enabled}
|
||||
square
|
||||
legacyStyle
|
||||
variant={variant}
|
||||
tooltip={t(tooltipLabel)}
|
||||
onPress={(e) =>
|
||||
onPress={(e) => {
|
||||
buttonProps.onClick?.(
|
||||
e as unknown as React.MouseEvent<HTMLButtonElement, MouseEvent>
|
||||
)
|
||||
}
|
||||
style={{
|
||||
maxWidth: '46px',
|
||||
maxHeight: '46px',
|
||||
onPress?.(e)
|
||||
}}
|
||||
data-attr={`controls-screenshare-${tooltipLabel}`}
|
||||
{...props}
|
||||
>
|
||||
<Div position="relative">
|
||||
<RiRectangleLine size={28} />
|
||||
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
useTrackToggle,
|
||||
UseTrackToggleProps,
|
||||
} from '@livekit/components-react'
|
||||
import { HStack } from '@/styled-system/jsx'
|
||||
import { Button, Menu, MenuList } from '@/primitives'
|
||||
import {
|
||||
RemixiconComponentType,
|
||||
@@ -19,6 +18,7 @@ import { Track } from 'livekit-client'
|
||||
import { Shortcut } from '@/features/shortcuts/types'
|
||||
|
||||
import { ToggleDevice } from '@/features/rooms/livekit/components/controls/ToggleDevice.tsx'
|
||||
import { css } from '@/styled-system/css'
|
||||
|
||||
export type ToggleSource = Exclude<
|
||||
Track.Source,
|
||||
@@ -86,7 +86,12 @@ export const SelectToggleDevice = <T extends ToggleSource>({
|
||||
const selectLabel = t('choose', { keyPrefix: `join.${config.kind}` })
|
||||
|
||||
return (
|
||||
<HStack gap={0}>
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
gap: '1px',
|
||||
})}
|
||||
>
|
||||
<ToggleDevice {...trackProps} config={config} />
|
||||
<Menu>
|
||||
<Button
|
||||
@@ -94,6 +99,7 @@ export const SelectToggleDevice = <T extends ToggleSource>({
|
||||
aria-label={selectLabel}
|
||||
groupPosition="right"
|
||||
square
|
||||
variant={trackProps.enabled ? 'primaryDark' : 'error2'}
|
||||
>
|
||||
<RiArrowDownSLine />
|
||||
</Button>
|
||||
@@ -109,6 +115,6 @@ export const SelectToggleDevice = <T extends ToggleSource>({
|
||||
}}
|
||||
/>
|
||||
</Menu>
|
||||
</HStack>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { SettingsDialogExtended } from '@/features/settings/components/SettingsDialogExtended'
|
||||
import React, { createContext, useContext, useState } from 'react'
|
||||
|
||||
const SettingsDialogContext = createContext<
|
||||
| {
|
||||
dialogOpen: boolean
|
||||
setDialogOpen: React.Dispatch<React.SetStateAction<boolean>>
|
||||
}
|
||||
| undefined
|
||||
>(undefined)
|
||||
|
||||
export const SettingsDialogProvider: React.FC<{
|
||||
children: React.ReactNode
|
||||
}> = ({ children }) => {
|
||||
const [dialogOpen, setDialogOpen] = useState(false)
|
||||
return (
|
||||
<SettingsDialogContext.Provider value={{ dialogOpen, setDialogOpen }}>
|
||||
{children}
|
||||
<SettingsDialogExtended
|
||||
isOpen={dialogOpen}
|
||||
onOpenChange={(v) => !v && setDialogOpen(false)}
|
||||
/>
|
||||
</SettingsDialogContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
export const useSettingsDialog = () => {
|
||||
const context = useContext(SettingsDialogContext)
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
'useSettingsDialog must be used within a SettingsDialogProvider'
|
||||
)
|
||||
}
|
||||
return context
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
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'
|
||||
|
||||
export const SupportToggle = ({ onPress, ...props }: ToggleButtonProps) => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'controls' })
|
||||
const [isOpened, setIsOpened] = useState($crisp.is('chat:opened'))
|
||||
|
||||
useEffect(() => {
|
||||
if (!Crisp) {
|
||||
return
|
||||
}
|
||||
|
||||
Crisp.chat.onChatOpened(() => {
|
||||
setIsOpened(true)
|
||||
})
|
||||
Crisp.chat.onChatClosed(() => {
|
||||
setIsOpened(false)
|
||||
})
|
||||
return () => {
|
||||
Crisp.chat.offChatOpened()
|
||||
Crisp.chat.offChatClosed()
|
||||
}
|
||||
}, [])
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -57,9 +57,9 @@ export const ToggleDevice = ({
|
||||
|
||||
return (
|
||||
<ToggleButton
|
||||
isSelected={enabled}
|
||||
variant={enabled ? undefined : 'danger'}
|
||||
toggledStyles={false}
|
||||
isSelected={!enabled}
|
||||
variant={enabled ? 'primaryDark' : 'error2'}
|
||||
shySelected
|
||||
onPress={() => toggle()}
|
||||
aria-label={toggleLabel}
|
||||
tooltip={toggleLabel}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { ToggleButton } from '@/primitives'
|
||||
import { RiBardLine } 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 = ({
|
||||
variant = 'primaryDark',
|
||||
onPress,
|
||||
...props
|
||||
}: ToggleButtonProps) => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'controls.transcript' })
|
||||
|
||||
const { isTranscriptOpen, toggleTranscript } = useSidePanel()
|
||||
const tooltipLabel = isTranscriptOpen ? 'open' : 'closed'
|
||||
|
||||
const hasTranscriptAccess = useHasTranscriptAccess()
|
||||
|
||||
if (!hasTranscriptAccess) return
|
||||
|
||||
return (
|
||||
<div
|
||||
className={css({
|
||||
position: 'relative',
|
||||
display: 'inline-block',
|
||||
})}
|
||||
>
|
||||
<ToggleButton
|
||||
square
|
||||
variant={variant}
|
||||
aria-label={t(tooltipLabel)}
|
||||
tooltip={t(tooltipLabel)}
|
||||
isSelected={isTranscriptOpen}
|
||||
onPress={(e) => {
|
||||
toggleTranscript()
|
||||
onPress?.(e)
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
<RiBardLine />
|
||||
</ToggleButton>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { useFeatureFlagEnabled } from 'posthog-js/react'
|
||||
import { useIsAnalyticsEnabled } from '@/features/analytics/hooks/useIsAnalyticsEnabled'
|
||||
import { useIsTranscriptEnabled } from './useIsTranscriptEnabled'
|
||||
import { useIsAdminOrOwner } from './useIsAdminOrOwner'
|
||||
|
||||
export const useHasTranscriptAccess = () => {
|
||||
const featureEnabled = useFeatureFlagEnabled('transcription-summary')
|
||||
const isAnalyticsEnabled = useIsAnalyticsEnabled()
|
||||
const isTranscriptEnabled = useIsTranscriptEnabled()
|
||||
const isAdminOrOwner = useIsAdminOrOwner()
|
||||
|
||||
return (
|
||||
(featureEnabled || !isAnalyticsEnabled) &&
|
||||
isAdminOrOwner &&
|
||||
isTranscriptEnabled
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { useRoomData } from './useRoomData'
|
||||
|
||||
export const useIsAdminOrOwner = () => {
|
||||
const apiRoomData = useRoomData()
|
||||
return apiRoomData?.is_administrable
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { RecordingMode } from '@/features/rooms/api/startRecording'
|
||||
import { useConfig } from '@/api/useConfig'
|
||||
|
||||
export const useIsTranscriptEnabled = () => {
|
||||
const { data } = useConfig()
|
||||
|
||||
return (
|
||||
data?.recording?.is_enabled &&
|
||||
data?.recording?.available_modes?.includes(RecordingMode.Transcript)
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { useRoomData } from './useRoomData'
|
||||
|
||||
export const useRoomId = () => {
|
||||
const apiRoomData = useRoomData()
|
||||
return apiRoomData?.livekit?.room
|
||||
}
|
||||
@@ -5,6 +5,7 @@ export enum PanelId {
|
||||
PARTICIPANTS = 'participants',
|
||||
EFFECTS = 'effects',
|
||||
CHAT = 'chat',
|
||||
TRANSCRIPT = 'transcript',
|
||||
}
|
||||
|
||||
export const useSidePanel = () => {
|
||||
@@ -14,6 +15,7 @@ export const useSidePanel = () => {
|
||||
const isParticipantsOpen = activePanelId == PanelId.PARTICIPANTS
|
||||
const isEffectsOpen = activePanelId == PanelId.EFFECTS
|
||||
const isChatOpen = activePanelId == PanelId.CHAT
|
||||
const isTranscriptOpen = activePanelId == PanelId.TRANSCRIPT
|
||||
const isSidePanelOpen = !!activePanelId
|
||||
|
||||
const toggleParticipants = () => {
|
||||
@@ -28,14 +30,20 @@ export const useSidePanel = () => {
|
||||
layoutStore.activePanelId = isEffectsOpen ? null : PanelId.EFFECTS
|
||||
}
|
||||
|
||||
const toggleTranscript = () => {
|
||||
layoutStore.activePanelId = isTranscriptOpen ? null : PanelId.TRANSCRIPT
|
||||
}
|
||||
|
||||
return {
|
||||
activePanelId,
|
||||
toggleParticipants,
|
||||
toggleChat,
|
||||
toggleEffects,
|
||||
toggleTranscript,
|
||||
isChatOpen,
|
||||
isParticipantsOpen,
|
||||
isEffectsOpen,
|
||||
isSidePanelOpen,
|
||||
isTranscriptOpen,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,156 +0,0 @@
|
||||
import { Track } from 'livekit-client'
|
||||
import * as React from 'react'
|
||||
|
||||
import { supportsScreenSharing } from '@livekit/components-core'
|
||||
|
||||
import {
|
||||
useMaybeLayoutContext,
|
||||
usePersistentUserChoices,
|
||||
} from '@livekit/components-react'
|
||||
|
||||
import { StartMediaButton } from '../components/controls/StartMediaButton'
|
||||
import { useMediaQuery } from '../hooks/useMediaQuery'
|
||||
import { OptionsButton } from '../components/controls/Options/OptionsButton'
|
||||
import { ParticipantsToggle } from '../components/controls/Participants/ParticipantsToggle'
|
||||
import { ChatToggle } from '../components/controls/ChatToggle'
|
||||
import { HandToggle } from '../components/controls/HandToggle'
|
||||
import { SelectToggleDevice } from '../components/controls/SelectToggleDevice'
|
||||
import { LeaveButton } from '../components/controls/LeaveButton'
|
||||
import { ScreenShareToggle } from '../components/controls/ScreenShareToggle'
|
||||
import { css } from '@/styled-system/css'
|
||||
|
||||
/** @public */
|
||||
export type ControlBarControls = {
|
||||
microphone?: boolean
|
||||
camera?: boolean
|
||||
chat?: boolean
|
||||
screenShare?: boolean
|
||||
leave?: boolean
|
||||
settings?: boolean
|
||||
}
|
||||
|
||||
/** @public */
|
||||
export interface ControlBarProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
onDeviceError?: (error: { source: Track.Source; error: Error }) => void
|
||||
variation?: 'minimal' | 'verbose' | 'textOnly'
|
||||
controls?: ControlBarControls
|
||||
/**
|
||||
* If `true`, the user's device choices will be persisted.
|
||||
* This will enable the user to have the same device choices when they rejoin the room.
|
||||
* @defaultValue true
|
||||
* @alpha
|
||||
*/
|
||||
saveUserChoices?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* The `ControlBar` prefab gives the user the basic user interface to control their
|
||||
* media devices (camera, microphone and screen share), open the `Chat` and leave the room.
|
||||
*
|
||||
* @remarks
|
||||
* This component is build with other LiveKit components like `TrackToggle`,
|
||||
* `DeviceSelectorButton`, `DisconnectButton` and `StartAudio`.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <LiveKitRoom>
|
||||
* <ControlBar />
|
||||
* </LiveKitRoom>
|
||||
* ```
|
||||
* @public
|
||||
*/
|
||||
export function ControlBar({
|
||||
variation,
|
||||
saveUserChoices = true,
|
||||
onDeviceError,
|
||||
}: ControlBarProps) {
|
||||
const [isChatOpen, setIsChatOpen] = React.useState(false)
|
||||
const layoutContext = useMaybeLayoutContext()
|
||||
React.useEffect(() => {
|
||||
if (layoutContext?.widget.state?.showChat !== undefined) {
|
||||
setIsChatOpen(layoutContext?.widget.state?.showChat)
|
||||
}
|
||||
}, [layoutContext?.widget.state?.showChat])
|
||||
|
||||
const isTooLittleSpace = useMediaQuery(
|
||||
`(max-width: ${isChatOpen ? 1000 : 760}px)`
|
||||
)
|
||||
|
||||
const defaultVariation = isTooLittleSpace ? 'minimal' : 'verbose'
|
||||
variation ??= defaultVariation
|
||||
|
||||
const browserSupportsScreenSharing = supportsScreenSharing()
|
||||
|
||||
const {
|
||||
saveAudioInputEnabled,
|
||||
saveVideoInputEnabled,
|
||||
saveAudioInputDeviceId,
|
||||
saveVideoInputDeviceId,
|
||||
} = usePersistentUserChoices({ preventSave: !saveUserChoices })
|
||||
|
||||
const microphoneOnChange = React.useCallback(
|
||||
(enabled: boolean, isUserInitiated: boolean) =>
|
||||
isUserInitiated ? saveAudioInputEnabled(enabled) : null,
|
||||
[saveAudioInputEnabled]
|
||||
)
|
||||
|
||||
const cameraOnChange = React.useCallback(
|
||||
(enabled: boolean, isUserInitiated: boolean) =>
|
||||
isUserInitiated ? saveVideoInputEnabled(enabled) : null,
|
||||
[saveVideoInputEnabled]
|
||||
)
|
||||
|
||||
return (
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
gap: '.5rem',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: '.75rem',
|
||||
borderTop: '1px solid var(--lk-border-color)',
|
||||
maxHeight: 'var(--lk-control-bar-height)',
|
||||
height: '80px',
|
||||
position: 'absolute',
|
||||
backgroundColor: '#d1d5db',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
})}
|
||||
>
|
||||
<SelectToggleDevice
|
||||
source={Track.Source.Microphone}
|
||||
onChange={microphoneOnChange}
|
||||
onDeviceError={(error) =>
|
||||
onDeviceError?.({ source: Track.Source.Microphone, error })
|
||||
}
|
||||
onActiveDeviceChange={(deviceId) =>
|
||||
saveAudioInputDeviceId(deviceId ?? '')
|
||||
}
|
||||
/>
|
||||
<SelectToggleDevice
|
||||
source={Track.Source.Camera}
|
||||
onChange={cameraOnChange}
|
||||
onDeviceError={(error) =>
|
||||
onDeviceError?.({ source: Track.Source.Camera, error })
|
||||
}
|
||||
onActiveDeviceChange={(deviceId) =>
|
||||
saveVideoInputDeviceId(deviceId ?? '')
|
||||
}
|
||||
/>
|
||||
{browserSupportsScreenSharing && (
|
||||
<ScreenShareToggle
|
||||
onDeviceError={(error) =>
|
||||
onDeviceError?.({ source: Track.Source.ScreenShare, error })
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<HandToggle />
|
||||
<ChatToggle />
|
||||
<ParticipantsToggle />
|
||||
<OptionsButton />
|
||||
<LeaveButton />
|
||||
<StartMediaButton />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import { Track } from 'livekit-client'
|
||||
import * as React from 'react'
|
||||
import { usePersistentUserChoices } from '@livekit/components-react'
|
||||
|
||||
import { MobileControlBar } from './MobileControlBar'
|
||||
import { DesktopControlBar } from './DesktopControlBar'
|
||||
import { SettingsDialogProvider } from '../../components/controls/SettingsDialogContext'
|
||||
import { useIsMobile } from '@/utils/useIsMobile'
|
||||
|
||||
/** @public */
|
||||
export type ControlBarControls = {
|
||||
microphone?: boolean
|
||||
camera?: boolean
|
||||
chat?: boolean
|
||||
screenShare?: boolean
|
||||
leave?: boolean
|
||||
settings?: boolean
|
||||
}
|
||||
|
||||
/** @public */
|
||||
export interface ControlBarProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
onDeviceError?: (error: { source: Track.Source; error: Error }) => void
|
||||
variation?: 'minimal' | 'verbose' | 'textOnly'
|
||||
controls?: ControlBarControls
|
||||
/**
|
||||
* If `true`, the user's device choices will be persisted.
|
||||
* This will enable the user to have the same device choices when they rejoin the room.
|
||||
* @defaultValue true
|
||||
* @alpha
|
||||
*/
|
||||
saveUserChoices?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* The `ControlBar` prefab gives the user the basic user interface to control their
|
||||
* media devices (camera, microphone and screen share), open the `Chat` and leave the room.
|
||||
*
|
||||
* @remarks
|
||||
* This component is build with other LiveKit components like `TrackToggle`,
|
||||
* `DeviceSelectorButton`, `DisconnectButton` and `StartAudio`.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <LiveKitRoom>
|
||||
* <ControlBar />
|
||||
* </LiveKitRoom>
|
||||
* ```
|
||||
* @public
|
||||
*/
|
||||
export function ControlBar({
|
||||
saveUserChoices = true,
|
||||
onDeviceError,
|
||||
}: ControlBarProps) {
|
||||
const {
|
||||
saveAudioInputEnabled,
|
||||
saveVideoInputEnabled,
|
||||
saveAudioInputDeviceId,
|
||||
saveVideoInputDeviceId,
|
||||
} = usePersistentUserChoices({ preventSave: !saveUserChoices })
|
||||
|
||||
const microphoneOnChange = React.useCallback(
|
||||
(enabled: boolean, isUserInitiated: boolean) =>
|
||||
isUserInitiated ? saveAudioInputEnabled(enabled) : null,
|
||||
[saveAudioInputEnabled]
|
||||
)
|
||||
|
||||
const cameraOnChange = React.useCallback(
|
||||
(enabled: boolean, isUserInitiated: boolean) =>
|
||||
isUserInitiated ? saveVideoInputEnabled(enabled) : null,
|
||||
[saveVideoInputEnabled]
|
||||
)
|
||||
|
||||
const barProps = {
|
||||
onDeviceError,
|
||||
microphoneOnChange,
|
||||
cameraOnChange,
|
||||
saveAudioInputDeviceId,
|
||||
saveVideoInputDeviceId,
|
||||
}
|
||||
|
||||
const isMobile = useIsMobile()
|
||||
|
||||
return (
|
||||
<SettingsDialogProvider>
|
||||
{isMobile ? (
|
||||
<MobileControlBar {...barProps} />
|
||||
) : (
|
||||
<DesktopControlBar {...barProps} />
|
||||
)}
|
||||
</SettingsDialogProvider>
|
||||
)
|
||||
}
|
||||
|
||||
export interface ControlBarAuxProps {
|
||||
onDeviceError: ControlBarProps['onDeviceError']
|
||||
microphoneOnChange: (
|
||||
enabled: boolean,
|
||||
isUserInitiated: boolean
|
||||
) => void | null
|
||||
cameraOnChange: (enabled: boolean, isUserInitiated: boolean) => void | null
|
||||
saveAudioInputDeviceId: (deviceId: string) => void
|
||||
saveVideoInputDeviceId: (deviceId: string) => void
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import { supportsScreenSharing } from '@livekit/components-core'
|
||||
import { ControlBarAuxProps } from './ControlBar'
|
||||
import { css } from '@/styled-system/css'
|
||||
import { LeaveButton } from '../../components/controls/LeaveButton'
|
||||
import { SelectToggleDevice } from '../../components/controls/SelectToggleDevice'
|
||||
import { Track } from 'livekit-client'
|
||||
import { HandToggle } from '../../components/controls/HandToggle'
|
||||
import { ScreenShareToggle } from '../../components/controls/ScreenShareToggle'
|
||||
import { OptionsButton } from '../../components/controls/Options/OptionsButton'
|
||||
import { StartMediaButton } from '../../components/controls/StartMediaButton'
|
||||
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'
|
||||
|
||||
export function DesktopControlBar({
|
||||
onDeviceError,
|
||||
microphoneOnChange,
|
||||
cameraOnChange,
|
||||
saveAudioInputDeviceId,
|
||||
saveVideoInputDeviceId,
|
||||
}: ControlBarAuxProps) {
|
||||
const browserSupportsScreenSharing = supportsScreenSharing()
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={css({
|
||||
width: '100vw',
|
||||
display: 'flex',
|
||||
position: 'absolute',
|
||||
padding: '1.125rem',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-start',
|
||||
flex: '1 1 33%',
|
||||
alignItems: 'center',
|
||||
gap: '0.5rem',
|
||||
marginLeft: '0.5rem',
|
||||
})}
|
||||
></div>
|
||||
<div
|
||||
className={css({
|
||||
flex: '1 1 33%',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
display: 'flex',
|
||||
gap: '0.65rem',
|
||||
})}
|
||||
>
|
||||
<SelectToggleDevice
|
||||
source={Track.Source.Microphone}
|
||||
onChange={microphoneOnChange}
|
||||
onDeviceError={(error) =>
|
||||
onDeviceError?.({ source: Track.Source.Microphone, error })
|
||||
}
|
||||
onActiveDeviceChange={(deviceId) =>
|
||||
saveAudioInputDeviceId(deviceId ?? '')
|
||||
}
|
||||
/>
|
||||
<SelectToggleDevice
|
||||
source={Track.Source.Camera}
|
||||
onChange={cameraOnChange}
|
||||
onDeviceError={(error) =>
|
||||
onDeviceError?.({ source: Track.Source.Camera, error })
|
||||
}
|
||||
onActiveDeviceChange={(deviceId) =>
|
||||
saveVideoInputDeviceId(deviceId ?? '')
|
||||
}
|
||||
/>
|
||||
{browserSupportsScreenSharing && (
|
||||
<ScreenShareToggle
|
||||
onDeviceError={(error) =>
|
||||
onDeviceError?.({ source: Track.Source.ScreenShare, error })
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<HandToggle />
|
||||
<OptionsButton />
|
||||
<LeaveButton />
|
||||
<StartMediaButton />
|
||||
</div>
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-end',
|
||||
flex: '1 1 33%',
|
||||
alignItems: 'center',
|
||||
gap: '0.5rem',
|
||||
paddingRight: '0.25rem',
|
||||
})}
|
||||
>
|
||||
<ChatToggle />
|
||||
<ParticipantsToggle />
|
||||
<TranscriptToggle />
|
||||
<SupportToggle />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
import { supportsScreenSharing } from '@livekit/components-core'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { ControlBarAuxProps } from './ControlBar'
|
||||
import React from 'react'
|
||||
import { css } from '@/styled-system/css'
|
||||
import { LeaveButton } from '../../components/controls/LeaveButton'
|
||||
import { SelectToggleDevice } from '../../components/controls/SelectToggleDevice'
|
||||
import { Track } from 'livekit-client'
|
||||
import { HandToggle } from '../../components/controls/HandToggle'
|
||||
import { Button } from '@/primitives/Button'
|
||||
import {
|
||||
RiAccountBoxLine,
|
||||
RiMegaphoneLine,
|
||||
RiMore2Line,
|
||||
RiSettings3Line,
|
||||
} from '@remixicon/react'
|
||||
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'
|
||||
|
||||
export function MobileControlBar({
|
||||
onDeviceError,
|
||||
microphoneOnChange,
|
||||
cameraOnChange,
|
||||
saveAudioInputDeviceId,
|
||||
saveVideoInputDeviceId,
|
||||
}: ControlBarAuxProps) {
|
||||
const { t } = useTranslation('rooms')
|
||||
const [isMenuOpened, setIsMenuOpened] = React.useState(false)
|
||||
const browserSupportsScreenSharing = supportsScreenSharing()
|
||||
const { toggleEffects } = useSidePanel()
|
||||
const { setDialogOpen } = useSettingsDialog()
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={css({
|
||||
width: '100vw',
|
||||
display: 'flex',
|
||||
position: 'absolute',
|
||||
padding: '1.125rem',
|
||||
justifyContent: 'center',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
width: '422px',
|
||||
})}
|
||||
>
|
||||
<LeaveButton />
|
||||
<SelectToggleDevice
|
||||
source={Track.Source.Microphone}
|
||||
onChange={microphoneOnChange}
|
||||
onDeviceError={(error) =>
|
||||
onDeviceError?.({ source: Track.Source.Microphone, error })
|
||||
}
|
||||
onActiveDeviceChange={(deviceId) =>
|
||||
saveAudioInputDeviceId(deviceId ?? '')
|
||||
}
|
||||
/>
|
||||
<SelectToggleDevice
|
||||
source={Track.Source.Camera}
|
||||
onChange={cameraOnChange}
|
||||
onDeviceError={(error) =>
|
||||
onDeviceError?.({ source: Track.Source.Camera, error })
|
||||
}
|
||||
onActiveDeviceChange={(deviceId) =>
|
||||
saveVideoInputDeviceId(deviceId ?? '')
|
||||
}
|
||||
/>
|
||||
<HandToggle />
|
||||
<Button
|
||||
square
|
||||
variant="primaryDark"
|
||||
aria-label={t('options.buttonLabel')}
|
||||
tooltip={t('options.buttonLabel')}
|
||||
onPress={() => setIsMenuOpened(true)}
|
||||
>
|
||||
<RiMore2Line />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<ResponsiveMenu
|
||||
isOpened={isMenuOpened}
|
||||
onClosed={() => setIsMenuOpened(false)}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
flexGrow: 1,
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(100px, 1fr))',
|
||||
gridGap: '1rem',
|
||||
'& > *': {
|
||||
alignSelf: 'center',
|
||||
justifySelf: 'center',
|
||||
},
|
||||
})}
|
||||
>
|
||||
{browserSupportsScreenSharing && (
|
||||
<ScreenShareToggle
|
||||
onDeviceError={(error) =>
|
||||
onDeviceError?.({ source: Track.Source.ScreenShare, error })
|
||||
}
|
||||
variant="primaryTextDark"
|
||||
description={true}
|
||||
onPress={() => setIsMenuOpened(false)}
|
||||
/>
|
||||
)}
|
||||
<ChatToggle
|
||||
description={true}
|
||||
onPress={() => setIsMenuOpened(false)}
|
||||
/>
|
||||
<ParticipantsToggle
|
||||
description={true}
|
||||
onPress={() => setIsMenuOpened(false)}
|
||||
/>
|
||||
<TranscriptToggle
|
||||
description={true}
|
||||
onPress={() => setIsMenuOpened(false)}
|
||||
/>
|
||||
<SupportToggle
|
||||
description={true}
|
||||
onPress={() => setIsMenuOpened(false)}
|
||||
/>
|
||||
<Button
|
||||
onPress={() => {
|
||||
toggleEffects()
|
||||
setIsMenuOpened(false)
|
||||
}}
|
||||
variant="primaryTextDark"
|
||||
aria-label={t('options.items.effects')}
|
||||
tooltip={t('options.items.effects')}
|
||||
description={true}
|
||||
>
|
||||
<RiAccountBoxLine size={20} />
|
||||
</Button>
|
||||
<LinkButton
|
||||
href="https://grist.incubateur.net/o/docs/forms/1YrfNP1QSSy8p2gCxMFnSf/4"
|
||||
variant="primaryTextDark"
|
||||
tooltip={t('options.items.feedbacks')}
|
||||
aria-label={t('options.items.feedbacks')}
|
||||
description={true}
|
||||
target="_blank"
|
||||
onPress={() => setIsMenuOpened(false)}
|
||||
>
|
||||
<RiMegaphoneLine size={20} />
|
||||
</LinkButton>
|
||||
<Button
|
||||
onPress={() => {
|
||||
setDialogOpen(true)
|
||||
setIsMenuOpened(false)
|
||||
}}
|
||||
variant="primaryTextDark"
|
||||
aria-label={t('options.items.settings')}
|
||||
tooltip={t('options.items.settings')}
|
||||
description={true}
|
||||
>
|
||||
<RiSettings3Line size={20} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</ResponsiveMenu>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { css } from '@/styled-system/css'
|
||||
import { PropsWithChildren } from 'react'
|
||||
import { Dialog, Modal, ModalOverlay } from 'react-aria-components'
|
||||
|
||||
interface ResponsiveMenuProps extends PropsWithChildren {
|
||||
isOpened: boolean
|
||||
onClosed: () => void
|
||||
}
|
||||
|
||||
export function ResponsiveMenu({
|
||||
isOpened,
|
||||
onClosed,
|
||||
children,
|
||||
}: ResponsiveMenuProps) {
|
||||
return (
|
||||
<ModalOverlay
|
||||
isDismissable
|
||||
isOpen={isOpened}
|
||||
onOpenChange={(isOpened) => {
|
||||
if (!isOpened) {
|
||||
onClosed()
|
||||
}
|
||||
}}
|
||||
className={css({
|
||||
width: '100vw',
|
||||
height: 'var(--visual-viewport-height)',
|
||||
zIndex: 100,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'flex-end',
|
||||
display: 'flex',
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
padding: '1.5rem 1.5rem 1rem 1.5rem',
|
||||
boxSizing: 'border-box',
|
||||
})}
|
||||
>
|
||||
<Modal
|
||||
className={css({
|
||||
backgroundColor: 'primaryDark.200',
|
||||
borderRadius: '20px',
|
||||
flexGrow: 1,
|
||||
padding: '1.5rem',
|
||||
'&[data-entering]': {
|
||||
animation: 'slide-full 200ms',
|
||||
},
|
||||
'&[data-exiting]': {
|
||||
animation: 'slide-full 200ms reverse',
|
||||
},
|
||||
})}
|
||||
>
|
||||
<Dialog>{children}</Dialog>
|
||||
</Modal>
|
||||
</ModalOverlay>
|
||||
)
|
||||
}
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
useCreateLayoutContext,
|
||||
} from '@livekit/components-react'
|
||||
|
||||
import { ControlBar } from './ControlBar'
|
||||
import { ControlBar } from './ControlBar/ControlBar'
|
||||
import { styled } from '@/styled-system/jsx'
|
||||
import { cva } from '@/styled-system/css'
|
||||
import { MainNotificationToast } from '@/features/notifications/MainNotificationToast'
|
||||
@@ -28,6 +28,7 @@ import { FocusLayout } from '../components/FocusLayout'
|
||||
import { ParticipantTile } from '../components/ParticipantTile'
|
||||
import { SidePanel } from '../components/SidePanel'
|
||||
import { useSidePanel } from '../hooks/useSidePanel'
|
||||
import { RecordingStateToast } from '../components/RecordingStateToast'
|
||||
|
||||
const LayoutWrapper = styled(
|
||||
'div',
|
||||
@@ -212,6 +213,7 @@ export function VideoConference({ ...props }: VideoConferenceProps) {
|
||||
)}
|
||||
<RoomAudioRenderer />
|
||||
<ConnectionStateToast />
|
||||
<RecordingStateToast />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -23,16 +23,12 @@ export const FeedbackRoute = () => {
|
||||
const { t } = useTranslation('rooms')
|
||||
const [, setLocation] = useLocation()
|
||||
return (
|
||||
<Screen layout="centered">
|
||||
<Screen layout="centered" footer={false}>
|
||||
<Center>
|
||||
<VStack>
|
||||
<Heading>{t('feedback.heading')}</Heading>
|
||||
<HStack>
|
||||
<Button
|
||||
outline
|
||||
variant="primary"
|
||||
onPress={() => window.history.back()}
|
||||
>
|
||||
<Button variant="secondary" onPress={() => window.history.back()}>
|
||||
{t('feedback.back')}
|
||||
</Button>
|
||||
<Button variant="primary" onPress={() => setLocation('/')}>
|
||||
|
||||
@@ -10,7 +10,7 @@ export const SettingsButton = () => {
|
||||
<DialogTrigger>
|
||||
<Button
|
||||
square
|
||||
invisible
|
||||
variant="secondaryText"
|
||||
aria-label={t('settingsButtonLabel')}
|
||||
tooltip={t('settingsButtonLabel')}
|
||||
>
|
||||
|
||||
@@ -6,12 +6,14 @@ import { Heading } from 'react-aria-components'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
RiAccountCircleLine,
|
||||
RiNotification3Line,
|
||||
RiSettings3Line,
|
||||
RiSpeakerLine,
|
||||
} from '@remixicon/react'
|
||||
import { AccountTab } from './tabs/AccountTab'
|
||||
import { GeneralTab } from '@/features/settings/components/tabs/GeneralTab.tsx'
|
||||
import { AudioTab } from '@/features/settings/components/tabs/AudioTab.tsx'
|
||||
import { NotificationsTab } from './tabs/NotificationsTab'
|
||||
import { GeneralTab } from './tabs/GeneralTab'
|
||||
import { AudioTab } from './tabs/AudioTab'
|
||||
import { useSize } from '@/features/rooms/livekit/hooks/useResizeObserver'
|
||||
import { useRef } from 'react'
|
||||
|
||||
@@ -81,12 +83,17 @@ export const SettingsDialogExtended = (props: SettingsDialogExtended) => {
|
||||
<RiSettings3Line />
|
||||
{isWideScreen && t('tabs.general')}
|
||||
</Tab>
|
||||
<Tab icon highlight id="4">
|
||||
<RiNotification3Line />
|
||||
{isWideScreen && t('tabs.notifications')}
|
||||
</Tab>
|
||||
</TabList>
|
||||
</div>
|
||||
<div className={tabPanelContainerStyle}>
|
||||
<AccountTab id="1" onOpenChange={props.onOpenChange} />
|
||||
<AudioTab id="2" />
|
||||
<GeneralTab id="3" />
|
||||
<NotificationsTab id="4" />
|
||||
</div>
|
||||
</Tabs>
|
||||
</Dialog>
|
||||
|
||||
@@ -68,7 +68,7 @@ export const AccountTab = ({ id, onOpenChange }: AccountTabProps) => {
|
||||
marginLeft: 'auto',
|
||||
})}
|
||||
>
|
||||
<Button onPress={handleOnCancel}>
|
||||
<Button variant="secondary" onPress={handleOnCancel}>
|
||||
{t('cancel', { ns: 'global' })}
|
||||
</Button>
|
||||
<Button variant={'primary'} onPress={handleOnSubmit}>
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { TabPanel, TabPanelProps } from '@/primitives/Tabs'
|
||||
import { H, Switch } from '@/primitives'
|
||||
import { css } from '@/styled-system/css'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useSnapshot } from 'valtio'
|
||||
import { notificationsStore } from '@/stores/notifications'
|
||||
|
||||
export type NotificationsTabProps = Pick<TabPanelProps, 'id'>
|
||||
|
||||
export const NotificationsTab = ({ id }: NotificationsTabProps) => {
|
||||
const { t } = useTranslation('settings', { keyPrefix: 'notifications' })
|
||||
const notificationsSnap = useSnapshot(notificationsStore)
|
||||
return (
|
||||
<TabPanel padding={'md'} flex id={id}>
|
||||
<H lvl={2}>{t('heading')}</H>
|
||||
<ul
|
||||
className={css({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '1rem',
|
||||
})}
|
||||
>
|
||||
{Array.from(notificationsSnap.soundNotifications).map(
|
||||
([key, value]) => (
|
||||
<li key={key}>
|
||||
<Switch
|
||||
aria-label={`${t(`actions.${value ? 'disable' : 'enable'}`)} ${t('label')} "${t(`items.${key}`)}"`}
|
||||
isSelected={value}
|
||||
onChange={(v) => {
|
||||
notificationsStore.soundNotifications.set(key, v)
|
||||
}}
|
||||
>
|
||||
{t(`items.${key}`)}
|
||||
</Switch>
|
||||
</li>
|
||||
)
|
||||
)}
|
||||
</ul>
|
||||
</TabPanel>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
import { styled } from '@/styled-system/jsx'
|
||||
import { css } from '@/styled-system/css'
|
||||
import { A } from '@/primitives'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
const StyledLi = styled('li', {
|
||||
base: {},
|
||||
variants: {
|
||||
divider: {
|
||||
true: {
|
||||
_after: {
|
||||
content: '""',
|
||||
display: 'inline-block',
|
||||
marginX: '.75rem',
|
||||
verticalAlign: 'middle',
|
||||
boxShadow: 'inset 0 0 0 1px #ddd',
|
||||
height: '1rem',
|
||||
width: '1px',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const InnerContainer = styled('div', {
|
||||
base: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'start',
|
||||
margin: 'auto',
|
||||
maxWidth: '1200px',
|
||||
paddingX: { base: '0.5rem', xs: '1rem', sm: '2rem' },
|
||||
},
|
||||
})
|
||||
|
||||
const MainLinkList = styled('ul', {
|
||||
base: {
|
||||
display: 'flex',
|
||||
gap: '0.5rem 1rem',
|
||||
flexWrap: 'wrap',
|
||||
flexBasis: { base: '100%', md: '50%' },
|
||||
},
|
||||
})
|
||||
|
||||
const FirstRow = styled('div', {
|
||||
base: {
|
||||
display: 'flex',
|
||||
gap: '2rem',
|
||||
flexWrap: { base: 'wrap', md: 'nowrap' },
|
||||
justifyContent: 'space-between',
|
||||
width: '100%',
|
||||
alignItems: 'flex-start',
|
||||
marginBottom: '1.5rem',
|
||||
},
|
||||
})
|
||||
|
||||
const SecondRow = styled('ul', {
|
||||
base: {
|
||||
display: 'flex',
|
||||
borderTop: '1px solid rgb(217 217 217)',
|
||||
paddingTop: '0.5rem',
|
||||
width: '100%',
|
||||
flexWrap: 'wrap',
|
||||
alignItems: 'center',
|
||||
},
|
||||
})
|
||||
|
||||
const ThirdRow = styled('p', {
|
||||
base: {
|
||||
fontSize: '0.75rem',
|
||||
color: 'rgb(77 77 77)',
|
||||
fontFamily: 'Marianne',
|
||||
textWrap: 'wrap',
|
||||
lineHeight: '1rem',
|
||||
marginTop: { base: '1rem', xs: '0.5rem' },
|
||||
},
|
||||
})
|
||||
|
||||
const Marianne = () => {
|
||||
return (
|
||||
<div
|
||||
className={css({
|
||||
_before: {
|
||||
content: '""',
|
||||
display: 'block',
|
||||
backgroundRepeat: 'no-repeat',
|
||||
backgroundSize: 'contain',
|
||||
backgroundImage: 'url(/assets/marianne.svg)',
|
||||
height: '1.25rem',
|
||||
marginBottom: '.2rem',
|
||||
width: '3rem',
|
||||
},
|
||||
_after: {
|
||||
content: '""',
|
||||
display: 'block',
|
||||
backgroundImage: 'url(/assets/devise.svg)',
|
||||
backgroundRepeat: 'no-repeat',
|
||||
backgroundSize: 'contain',
|
||||
height: '2.313rem',
|
||||
marginTop: '.2rem',
|
||||
width: '3.25rem',
|
||||
},
|
||||
})}
|
||||
>
|
||||
<p
|
||||
className={css({
|
||||
letterSpacing: '-.01em',
|
||||
textTransform: 'uppercase',
|
||||
fontWeight: '600',
|
||||
fontFamily: 'Marianne',
|
||||
fontSize: '1.25rem',
|
||||
lineHeight: '1.75rem',
|
||||
})}
|
||||
>
|
||||
gouvernement
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const Footer = () => {
|
||||
const { t } = useTranslation('global', { keyPrefix: 'footer' })
|
||||
|
||||
return (
|
||||
<footer
|
||||
className={css({
|
||||
borderTop: '2px solid rgb(0 0 145)',
|
||||
paddingY: '2rem',
|
||||
marginTop: { base: '50px', sm: '100px' },
|
||||
})}
|
||||
>
|
||||
<InnerContainer>
|
||||
<FirstRow>
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
paddingBottom: '1.5rem',
|
||||
paddingX: '1.5rem',
|
||||
alignItems: 'center',
|
||||
gap: '1.5rem',
|
||||
})}
|
||||
>
|
||||
<Marianne />
|
||||
<span
|
||||
className={css({
|
||||
height: '80px',
|
||||
backgroundColor: 'rgb(77 77 77)',
|
||||
width: '1px',
|
||||
display: { base: 'none', sm: 'block' },
|
||||
})}
|
||||
/>
|
||||
<p
|
||||
className={css({
|
||||
display: 'none',
|
||||
fontWeight: '700',
|
||||
fontFamily: 'Marianne',
|
||||
sm: {
|
||||
display: 'block',
|
||||
fontSize: '1rem',
|
||||
lineHeight: '1.5rem',
|
||||
},
|
||||
})}
|
||||
>
|
||||
Direction
|
||||
<br />
|
||||
interministérielle
|
||||
<br />
|
||||
du numérique
|
||||
</p>
|
||||
</div>
|
||||
<MainLinkList>
|
||||
<li>
|
||||
<A
|
||||
externalIcon
|
||||
underline={false}
|
||||
footer="important"
|
||||
href="https://legifrance.gouv.fr"
|
||||
aria-label={
|
||||
t('links.legifrance') + ' - ' + t('links.ariaLabel')
|
||||
}
|
||||
>
|
||||
{t('links.legifrance')}
|
||||
</A>
|
||||
</li>
|
||||
<li>
|
||||
<A
|
||||
externalIcon
|
||||
underline={false}
|
||||
footer="important"
|
||||
href="https://info.gouv.fr"
|
||||
aria-label={t('links.infogouv') + ' - ' + t('links.ariaLabel')}
|
||||
>
|
||||
{t('links.infogouv')}
|
||||
</A>
|
||||
</li>
|
||||
<li>
|
||||
<A
|
||||
externalIcon
|
||||
underline={false}
|
||||
footer="important"
|
||||
href="https://www.service-public.fr/"
|
||||
aria-label={
|
||||
t('links.servicepublic') + ' - ' + t('links.ariaLabel')
|
||||
}
|
||||
>
|
||||
{t('links.servicepublic')}
|
||||
</A>
|
||||
</li>
|
||||
<li>
|
||||
<A
|
||||
externalIcon
|
||||
underline={false}
|
||||
footer="important"
|
||||
href="https://data.gouv.fr"
|
||||
aria-label={t('links.datagouv') + ' - ' + t('links.ariaLabel')}
|
||||
>
|
||||
{t('links.datagouv')}
|
||||
</A>
|
||||
</li>
|
||||
</MainLinkList>
|
||||
</FirstRow>
|
||||
<SecondRow>
|
||||
<StyledLi divider>
|
||||
<A
|
||||
externalIcon
|
||||
underline={false}
|
||||
footer="minor"
|
||||
href="https://docs.numerique.gouv.fr/docs/f88a2eb0-7ce7-4016-b6ee-9f1fd1771951/"
|
||||
aria-label={t('links.legalsTerms') + ' - ' + t('links.ariaLabel')}
|
||||
>
|
||||
{t('links.legalsTerms')}
|
||||
</A>
|
||||
</StyledLi>
|
||||
<StyledLi divider>
|
||||
<A
|
||||
externalIcon
|
||||
underline={false}
|
||||
footer="minor"
|
||||
href="https://docs.numerique.gouv.fr/docs/168d7e8e-3f09-462d-8bbc-ea95dedd3889/"
|
||||
aria-label={t('links.data') + ' - ' + t('links.ariaLabel')}
|
||||
>
|
||||
{t('links.data')}
|
||||
</A>
|
||||
</StyledLi>
|
||||
<StyledLi>
|
||||
<A
|
||||
externalIcon
|
||||
underline={false}
|
||||
footer="minor"
|
||||
href="https://docs.numerique.gouv.fr/docs/94bd1e3b-a44d-4cf5-b7ee-708a5386a111/"
|
||||
aria-label={
|
||||
t('links.accessibility') + ' - ' + t('links.ariaLabel')
|
||||
}
|
||||
>
|
||||
{t('links.accessibility')}
|
||||
</A>
|
||||
</StyledLi>
|
||||
</SecondRow>
|
||||
<ThirdRow>
|
||||
{t('mentions')}{' '}
|
||||
<A
|
||||
externalIcon
|
||||
footer="minor"
|
||||
href="https://github.com/etalab/licence-ouverte/blob/master/LO.md"
|
||||
>
|
||||
{t('license')}
|
||||
</A>
|
||||
</ThirdRow>
|
||||
</InnerContainer>
|
||||
</footer>
|
||||
)
|
||||
}
|
||||
@@ -1,18 +1,98 @@
|
||||
import { Link } from 'wouter'
|
||||
import { css } from '@/styled-system/css'
|
||||
import { Stack } from '@/styled-system/jsx'
|
||||
import { HStack, Stack } from '@/styled-system/jsx'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Text, Button } from '@/primitives'
|
||||
import { Button } from '@/primitives'
|
||||
import { SettingsButton } from '@/features/settings'
|
||||
import { logoutUrl, useUser } from '@/features/auth'
|
||||
import { useMatchesRoute } from '@/navigation/useMatchesRoute'
|
||||
import { FeedbackBanner } from '@/components/FeedbackBanner.tsx'
|
||||
import { FeedbackBanner } from '@/components/FeedbackBanner'
|
||||
import { Menu } from '@/primitives/Menu'
|
||||
import { MenuList } from '@/primitives/MenuList'
|
||||
import { ProConnectButton } from '@/components/ProConnectButton'
|
||||
import { terminateAnalyticsSession } from '@/features/analytics/hooks/useAnalytics'
|
||||
import { terminateSupportSession } from '@/features/support/hooks/useSupport'
|
||||
|
||||
import LogoAsset from '@/assets/logo.svg'
|
||||
|
||||
const Marianne = () => {
|
||||
return (
|
||||
<div
|
||||
className={css({
|
||||
_before: {
|
||||
content: '""',
|
||||
display: 'block',
|
||||
backgroundImage: 'url(/assets/marianne.svg)',
|
||||
backgroundPosition: '0 -0.046875rem, 0 0, 0 0',
|
||||
backgroundSize: '2.0625rem 0.84375rem, 2.0625rem 0.75rem, 0',
|
||||
height: '0.75rem',
|
||||
marginBottom: '0.1rem',
|
||||
width: '2.0625rem',
|
||||
},
|
||||
_after: {
|
||||
content: '""',
|
||||
display: 'block',
|
||||
backgroundImage: 'url(/assets/devise.svg)',
|
||||
backgroundRepeat: 'no-repeat',
|
||||
backgroundSize: 'contain',
|
||||
width: '2.5rem',
|
||||
height: '1.7857142857142858rem',
|
||||
marginTop: '0.25rem',
|
||||
},
|
||||
})}
|
||||
>
|
||||
<p
|
||||
className={css({
|
||||
letterSpacing: '-.01em',
|
||||
textTransform: 'uppercase',
|
||||
fontWeight: '600',
|
||||
fontFamily: 'Marianne',
|
||||
fontSize: '0.7875rem !important',
|
||||
})}
|
||||
>
|
||||
gouvernement
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const BetaBadge = () => (
|
||||
<span
|
||||
className={css({
|
||||
content: '"Beta"',
|
||||
display: 'block',
|
||||
letterSpacing: '-0.02rem',
|
||||
padding: '0 0.25rem',
|
||||
backgroundColor: '#E8EDFF',
|
||||
color: '#0063CB',
|
||||
fontSize: '12px',
|
||||
fontWeight: 500,
|
||||
margin: '0 0 0.9375rem 0.3125rem',
|
||||
lineHeight: '1rem',
|
||||
borderRadius: '4px',
|
||||
width: 'fit-content',
|
||||
height: 'fit-content',
|
||||
marginTop: { base: '10px', sm: '5px' },
|
||||
})}
|
||||
>
|
||||
Beta
|
||||
</span>
|
||||
)
|
||||
|
||||
const Logo = () => {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<img
|
||||
src={LogoAsset}
|
||||
alt={t('app')}
|
||||
className={css({
|
||||
maxHeight: { base: '30px', sm: '40px' },
|
||||
marginTop: { base: '10px', sm: '5px' },
|
||||
})}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export const Header = () => {
|
||||
const { t } = useTranslation()
|
||||
const isHome = useMatchesRoute('home')
|
||||
@@ -24,37 +104,43 @@ export const Header = () => {
|
||||
<FeedbackBanner />
|
||||
<div
|
||||
className={css({
|
||||
paddingY: 1,
|
||||
paddingBottom: 1,
|
||||
paddingX: 1,
|
||||
paddingTop: 0.25,
|
||||
flexShrink: 0,
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
rowGap: 0,
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
})}
|
||||
>
|
||||
<HStack gap={0} justify="space-between" alignItems="center">
|
||||
<header>
|
||||
<Stack gap={2.25} direction="row" align="center">
|
||||
<Text bold variant="h1" margin={false}>
|
||||
<Link
|
||||
onClick={(event) => {
|
||||
if (
|
||||
isRoom &&
|
||||
!window.confirm(t('leaveRoomPrompt', { ns: 'rooms' }))
|
||||
) {
|
||||
event.preventDefault()
|
||||
}
|
||||
}}
|
||||
to="/"
|
||||
>
|
||||
{t('app')}
|
||||
</Link>
|
||||
</Text>
|
||||
<Link
|
||||
className={css({
|
||||
display: 'flex',
|
||||
flexDirection: { base: 'column', sm: 'row' },
|
||||
alignItems: 'start',
|
||||
gap: { base: '0', sm: '2rem' },
|
||||
padding: { base: '0.5rem', sm: '1rem' },
|
||||
_hover: {
|
||||
backgroundColor: 'greyscale.100',
|
||||
borderRadius: '4px',
|
||||
},
|
||||
})}
|
||||
onClick={(event) => {
|
||||
if (
|
||||
isRoom &&
|
||||
!window.confirm(t('leaveRoomPrompt', { ns: 'rooms' }))
|
||||
) {
|
||||
event.preventDefault()
|
||||
}
|
||||
}}
|
||||
to="/"
|
||||
>
|
||||
<Marianne />
|
||||
<HStack gap={0}>
|
||||
<Logo />
|
||||
<BetaBadge />
|
||||
</HStack>
|
||||
</Link>
|
||||
</Stack>
|
||||
</header>
|
||||
<nav>
|
||||
@@ -66,7 +152,7 @@ export const Header = () => {
|
||||
<Menu>
|
||||
<Button
|
||||
size="sm"
|
||||
invisible
|
||||
variant="secondaryText"
|
||||
tooltip={t('loggedInUserTooltip')}
|
||||
tooltipType="delayed"
|
||||
>
|
||||
@@ -75,14 +161,15 @@ export const Header = () => {
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
display: 'block',
|
||||
maxWidth: { base: '80px', xsm: '350px' },
|
||||
maxWidth: '350px',
|
||||
display: { base: 'none', xsm: 'block' },
|
||||
})}
|
||||
>
|
||||
{user?.full_name || user?.email}
|
||||
</span>
|
||||
</Button>
|
||||
<MenuList
|
||||
variant={'light'}
|
||||
items={[{ value: 'logout', label: t('logout') }]}
|
||||
onAction={(value) => {
|
||||
if (value === 'logout') {
|
||||
@@ -97,7 +184,7 @@ export const Header = () => {
|
||||
<SettingsButton />
|
||||
</Stack>
|
||||
</nav>
|
||||
</div>
|
||||
</HStack>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -3,6 +3,7 @@ import { css } from '@/styled-system/css'
|
||||
import { Header } from './Header'
|
||||
import { layoutStore } from '@/stores/layout'
|
||||
import { useSnapshot } from 'valtio'
|
||||
import { Footer } from '@/layout/Footer'
|
||||
|
||||
export type Layout = 'fullpage' | 'centered'
|
||||
|
||||
@@ -15,28 +16,35 @@ export type Layout = 'fullpage' | 'centered'
|
||||
export const Layout = ({ children }: { children: ReactNode }) => {
|
||||
const layoutSnap = useSnapshot(layoutStore)
|
||||
const showHeader = layoutSnap.showHeader
|
||||
const showFooter = layoutSnap.showFooter
|
||||
|
||||
return (
|
||||
<div
|
||||
className={css({
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
backgroundColor: 'white',
|
||||
color: 'default.text',
|
||||
})}
|
||||
>
|
||||
{showHeader && <Header />}
|
||||
<main
|
||||
<>
|
||||
<div
|
||||
className={css({
|
||||
flexGrow: 1,
|
||||
overflow: 'auto',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
backgroundColor: 'white',
|
||||
color: 'default.text',
|
||||
flex: '1',
|
||||
})}
|
||||
style={{
|
||||
height: !showFooter ? '100%' : undefined,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
{showHeader && <Header />}
|
||||
<main
|
||||
className={css({
|
||||
flexGrow: 1,
|
||||
overflow: 'auto',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
})}
|
||||
>
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
{showFooter && <Footer />}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -13,18 +13,23 @@ export type ScreenProps = {
|
||||
* True by default. Pass undefined to render the screen without modifying current header visibility
|
||||
*/
|
||||
header?: boolean
|
||||
footer?: boolean
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
export const Screen = ({
|
||||
layout = 'fullpage',
|
||||
header = true,
|
||||
footer = true,
|
||||
children,
|
||||
}: ScreenProps) => {
|
||||
useEffect(() => {
|
||||
if (header !== undefined) {
|
||||
layoutStore.showHeader = header
|
||||
}
|
||||
}, [header])
|
||||
if (footer !== undefined) {
|
||||
layoutStore.showFooter = footer
|
||||
}
|
||||
}, [header, footer])
|
||||
return layout === 'centered' ? <Centered>{children}</Centered> : children
|
||||
}
|
||||
|
||||