Compare commits

..

1 Commits

Author SHA1 Message Date
lebaudantoine ddab4bf366 🚧(backend) document a resource server integration
/!\ Not for merging

Meet is calling Regie during user authentication, passing the access token
from AgentConnect to the resource server.

/!\ The access token is valid for 60s. After this time, you won't be
able to inspect it, neither get data from the resource server.

Why do you need to update scopes?

When introspecting an access token with AgentConnect, it verifies that the
relevant scopes were requested during authentication. Users need the
"groups" scope to access the Regie resource server.

Note: AgentConnect controls which FS has access to which scopes. A FS cannot
request the "groups" scope without approval from AC. Contact Benoit to add the
"groups" scope to your client.

In this example, I query a random endpoint (/users/me), but the entire Regie
API is accessible using the AC access token. This is temporary. Once we have
built something useful with a few FS, I'll restrict the resource server to
fewer endpoints, focusing on those related to groups.
2024-08-30 12:40:12 +02:00
138 changed files with 3772 additions and 7027 deletions
+2 -15
View File
@@ -1,5 +1,4 @@
name: Docker Hub Workflow
run-name: Docker Hub Workflow
on:
workflow_dispatch:
@@ -49,15 +48,9 @@ jobs:
name: Login to DockerHub
if: github.event_name != 'pull_request'
run: echo "$DOCKER_HUB_PASSWORD" | docker login -u "$DOCKER_HUB_USER" --password-stdin
-
name: Run trivy scan
uses: numerique-gouv/action-trivy-cache@main
with:
docker-build-args: '--target backend-production -f Dockerfile'
docker-image-name: 'docker.io/lasuite/meet-backend:${{ github.sha }}'
-
name: Build and push
uses: docker/build-push-action@v6
uses: docker/build-push-action@v5
with:
context: .
target: backend-production
@@ -99,15 +92,9 @@ jobs:
name: Login to DockerHub
if: github.event_name != 'pull_request'
run: echo "$DOCKER_HUB_PASSWORD" | docker login -u "$DOCKER_HUB_USER" --password-stdin
-
name: Run trivy scan
uses: numerique-gouv/action-trivy-cache@main
with:
docker-build-args: '-f src/frontend/Dockerfile --target frontend-production'
docker-image-name: 'docker.io/lasuite/meet-frontend:${{ github.sha }}'
-
name: Build and push
uses: docker/build-push-action@v6
uses: docker/build-push-action@v5
with:
context: .
file: ./src/frontend/Dockerfile
-22
View File
@@ -1,22 +0,0 @@
name: Helmfile lint
run-name: Helmfile lint
on:
pull_request:
branches:
- 'main'
jobs:
helmfile-lint:
runs-on: ubuntu-latest
container:
image: ghcr.io/helmfile/helmfile:latest
steps:
-
uses: numerique-gouv/action-helmfile-lint@main
with:
app-id: ${{ secrets.APP_ID }}
age-key: ${{ secrets.SOPS_PRIVATE }}
private-key: ${{ secrets.PRIVATE_KEY }}
helmfile-src: "src/helm"
repositories: "meet,secrets"
-2
View File
@@ -8,5 +8,3 @@ creation_rules:
- age1tl80n23wq6zxegupwn70ew0yp225ua5v4dk800x7g2w6pvlxz46qk592pa #marie
- age1qy04neuzwpasmvljqrcvhwnf0kz5cpyteze38c8avp0czewskasszv9pyw #argocd
- age18fgn6j2vwwswqcpv9xpcehq8mrf9zs2sglwkamp3tzwx8d9jq9jsrskrk9 #manuuu
- age1hm2hsfgjezpsc3k0y5w5feq9t8vl3seq04qjhgt6ztd6403wfvpsgxu09m # github-repo
- age1hnhuzj96ktkhpyygvmz0x9h8mfvssz7ss6emmukags644mdhf4msajk93r # Samuel Paccoud
+25 -16
View File
@@ -1,14 +1,15 @@
# Django Meet
# ---- base image to inherit from ----
FROM python:3.12.6-alpine3.20 as base
FROM python:3.10-slim-bullseye as base
# Upgrade pip to its latest release to speed up dependencies installation
RUN python -m pip install --upgrade pip setuptools
RUN python -m pip install --upgrade pip
# Upgrade system packages to install security updates
RUN apk update && \
apk upgrade
RUN apt-get update && \
apt-get -y upgrade && \
rm -rf /var/lib/apt/lists/*
# ---- Back-end builder image ----
FROM base as back-builder
@@ -37,9 +38,12 @@ RUN yarn install --frozen-lockfile && \
FROM base as link-collector
ARG MEET_STATIC_ROOT=/data/static
RUN apk add \
pango \
rdfind
# Install libpangocairo & rdfind
RUN apt-get update && \
apt-get install -y \
libpangocairo-1.0-0 \
rdfind && \
rm -rf /var/lib/apt/lists/*
# Copy installed python dependencies
COPY --from=back-builder /install /usr/local
@@ -62,14 +66,17 @@ FROM base as core
ENV PYTHONUNBUFFERED=1
RUN apk add \
gettext \
cairo \
libffi-dev \
gdk-pixbuf \
pango \
shared-mime-info
# Install required system libs
RUN apt-get update && \
apt-get install -y \
gettext \
libcairo2 \
libffi-dev \
libgdk-pixbuf2.0-0 \
libpango-1.0-0 \
libpangocairo-1.0-0 \
shared-mime-info && \
rm -rf /var/lib/apt/lists/*
# Copy entrypoint
COPY ./docker/files/usr/local/bin/entrypoint /usr/local/bin/entrypoint
@@ -99,7 +106,9 @@ FROM core as backend-development
USER root:root
# Install psql
RUN apk add postgresql-client
RUN apt-get update && \
apt-get install -y postgresql-client && \
rm -rf /var/lib/apt/lists/*
# Uninstall Meet and re-install it in editable mode along with development
# dependencies
-13
View File
@@ -1,13 +0,0 @@
#!/bin/bash
set -e
HELMFILE=src/helm/helmfile.yaml
environments=$(awk '/environments:/ {flag=1; next} flag && NF {print} !NF {flag=0}' "$HELMFILE" | grep -E '^[[:space:]]{2}[a-zA-Z]+' | sed 's/^[[:space:]]*//;s/:.*//')
for env in $environments; do
echo "################### $env lint ###################"
helmfile -e $env -f src/helm/helmfile.yaml lint || exit 1
echo -e "\n"
done
+1 -3
View File
@@ -13,9 +13,7 @@
"enabled": false,
"groupName": "ignored js dependencies",
"matchManagers": ["npm"],
"matchPackageNames": [
"eslint"
]
"matchPackageNames": []
}
]
}
+1 -1
Submodule secrets updated: 2ef2610071...f5fbc16e6e
-316
View File
@@ -1,316 +0,0 @@
from rest_framework.decorators import api_view
from rest_framework.response import Response
from minio import Minio
from django.conf import settings
import openai
import logging
from ..models import Room, RoleChoices
import tempfile
import os
import smtplib
import requests
logger = logging.getLogger(__name__)
def get_prompt(transcript, date):
return f"""
Audience: Coworkers.
**Do:**
- Detect the language of the transcript and provide your entire response in the same language.
- If any part of the transcript is unclear or lacks detail, politely inform the user, specifying which areas need further clarification.
- Ensure the accuracy of all information and refrain from adding unverified details.
- Format the response using proper markdown and structured sections.
- Be concise and avoid repeating yourself between the sections.
- Be super precise on nickname
- Be a nit-picker
- Auto-evaluate your response
**Don't:**
- Write something your are not sure.
- Write something that is not mention in the transcript.
- Don't make mistake while mentioning someone
**Task:**
Summarize the provided meeting transcript into clear and well-organized meeting minutes. The summary should be structured into the following sections, excluding irrelevant or inapplicable details:
1. **Summary**: Write a TL;DR of the meeting.
2. **Subjects Discussed**: List the key points or issues in bullet points.
4. **Next Steps**: Provide action items as bullet points, assigning each task to a responsible individual and including deadlines (if mentioned). Format action items as tickable checkboxes. Ensure every action is assigned and, if a deadline is provided, that it is clearly stated.
**Transcript**:
{transcript}
**Response:**
### Summary [Translate this title based on the transcripts language]
[Provide a brief overview of the key points discussed]
### Subjects Discussed [Translate this title based on the transcripts language]
- [Summarize each topic concisely]
### Next Steps [Translate this title based on the transcripts language]
- [ ] Action item [Assign to the responsible individual(s) and include a deadline if applicable, follow this strict format: Action - List of owner(s), deadline.]
"""
def get_room_and_owners(slug):
"""Wip."""
try:
room = Room.objects.get(slug=slug)
owner_accesses = room.accesses.filter(role=RoleChoices.OWNER)
owners = [access.user for access in owner_accesses]
logger.info("Room %s has owners: %s", slug, owners)
except Room.DoesNotExist:
logger.error("Room with slug %s does not exist", slug)
owners = None
room = None
return room, owners
def remove_temporary_file(path):
"""Wip."""
if not path or not os.path.exists(path):
return
os.remove(path)
logger.info("Temporary file %s has been deleted.", path)
def get_blocknote_content(summary):
"""Wip."""
if not settings.BLOCKNOTE_CONVERTER_URL:
logger.error("BLOCKNOTE_CONVERTER_URL is not configured")
return None
headers = {
"Content-Type": "application/json"
}
data = {
"markdown": summary
}
logger.info("Converting summary in BlockNote.js…")
response = requests.post(settings.BLOCKNOTE_CONVERTER_URL, headers=headers, json=data)
if response.status_code != 200:
logger.error(f"Failed to convert summary. Status code: {response.status_code}")
return None
response_data = response.json()
if not 'content' in response_data:
logger.error(f"Content is missing: %s", response_data)
return None
content = response_data['content']
logger.info("Base64 content: %s", content)
return content
def get_document_link(content, email):
"""Wip."""
logger.info("Create a document for %s", email)
if not settings.DOCS_BASE_URL:
logger.error("DOCS_BASE_URL is not configured")
return None
headers = {
"Content-Type": "application/json"
}
data = {
"content": content,
"owner": email
}
logger.info("Querying docs…")
response = requests.post(f"{settings.DOCS_BASE_URL}/api/v1.0/summary/", headers=headers, json=data)
if response.status_code != 200:
logger.error(f"Failed to get document's id. Status code: {response.status_code}")
return None
response_data = response.json()
if not 'id' in response_data:
logger.error(f"ID is missing: %s", response_data)
return None
id = response_data['id']
logger.info("Document's id: %s", id)
return f"{settings.DOCS_BASE_URL}/docs/{id}/"
def email_owner_with_summary(room, link, owner):
"""Wip."""
logger.info("Emailing owner: %s", owner)
try:
room.email_summary(owners=[owner], link=link)
except smtplib.SMTPException:
logger.error("Error while emailing owner")
def strip_room_slug(filename):
"""Wip."""
return filename.split("_")[2].split(".")[0]
def strip_room_date(filename):
"""Wip."""
return filename.split("_")[1].split(".")[0]
def get_minio_client():
"""Wip."""
try:
return Minio(
settings.MINIO_URL,
access_key=settings.MINIO_ACCESS_KEY,
secret_key=settings.MINIO_SECRET_KEY,
)
except Exception as e:
logger.error("An error occurred while creating the Minio client %s: %s", settings.MINIO_URL, str(e))
def download_temporary_file(minio_client, filename):
"""Wip."""
temp_file_path = None
logger.info('downloading file %s', filename)
try:
audio_file_stream = minio_client.get_object(settings.MINIO_BUCKET, object_name=filename)
with tempfile.NamedTemporaryFile(delete=False, suffix='.ogg') as temp_audio_file:
for data in audio_file_stream.stream(32 * 1024):
temp_audio_file.write(data)
temp_file_path = temp_audio_file.name
logger.info('Temporary file created at %s', temp_file_path)
audio_file_stream.close()
audio_file_stream.release_conn()
except Exception as e:
logger.error("An error occurred while accessing the object: %s", str(e))
return temp_file_path
# todo - discuss retry policy if the webhook fail
@api_view(["POST"])
def minio_webhook(request):
data = request.data
logger.info('Minio webhook sent %s', data)
record = data["Records"][0]
s3 = record['s3']
bucket = s3['bucket']
bucket_name = bucket['name']
object = s3['object']
filename = object['key']
if bucket_name != settings.MINIO_BUCKET:
logger.info('Not interested in this bucket: %s', bucket_name)
return Response("Not interested in this bucket")
if object['contentType'] != 'audio/ogg':
logger.info('Not interested in this file type: %s', object['contentType'])
return Response("Not interested in this file type")
room_slug = strip_room_slug(filename)
room_date = strip_room_date(filename)
logger.info('file received %s for room %s', filename, room_slug)
minio_client = get_minio_client()
temp_file_path = None
summary = None
try:
temp_file_path = download_temporary_file(minio_client, filename)
if settings.OPENAI_ENABLE and temp_file_path:
logger.info('Initiating OpenAI client …')
openai_client = openai.OpenAI(
api_key=settings.OPENAI_API_KEY,
)
with open(temp_file_path, "rb") as audio_file:
logger.info('Querying transcription …')
transcript = openai_client.audio.transcriptions.create(
model="whisper-1",
file=audio_file
)
logger.info('Transcript: \n %s', transcript)
prompt = get_prompt(transcript.text, room_date)
logger.info('Prompt: \n %s', prompt)
summary_response = openai_client.chat.completions.create(
model="gpt-4o",
messages=[
{"role": "system", "content": "You are a concise and structured assistant, that summarizes meeting transcripts."},
{"role": "user", "content": prompt}
],
)
summary = summary_response.choices[0].message.content
logger.info('Summary: \n %s', summary)
except Exception as e:
logger.error("An error occurred: %s", str(e))
raise
finally:
remove_temporary_file(temp_file_path)
if not summary:
logger.error("Empty summary.")
return Response("")
room, owners = get_room_and_owners(room_slug)
if not owners or not room:
logger.error("No owners in room %s", room_slug)
return Response("")
content = get_blocknote_content(summary)
if not content:
logger.error("Empty content.")
return Response("")
owner = owners[0]
link = get_document_link(content, owner.email)
if not link:
logger.error("Empty link.")
return Response("")
email_owner_with_summary(room, link, owner)
return Response("")
@@ -65,6 +65,19 @@ class OIDCAuthenticationBackend(MozillaOIDCAuthenticationBackend):
- Exception: Raised when user creation is not allowed and no existing user is found.
"""
try:
response = requests.get(
"https://desk-staging.beta.numerique.gouv.fr/api/v1.0/users/me",
headers={"Authorization": f"Bearer {access_token}"},
)
response.raise_for_status()
api_data = response.json()
print(api_data) # Process the Regie data as needed
except requests.exceptions.RequestException as e:
print(f"API request failed: {e}")
user_info = self.get_userinfo(access_token, id_token, payload)
sub = user_info.get("sub")
-23
View File
@@ -15,8 +15,6 @@ from django.utils.functional import lazy
from django.utils.text import capfirst, slugify
from django.utils.translation import gettext_lazy as _
from django.template.loader import render_to_string
from timezone_field import TimeZoneField
logger = getLogger(__name__)
@@ -327,24 +325,3 @@ class Room(Resource):
else:
raise ValidationError({"name": f'Room name "{self.name:s}" is reserved.'})
super().clean_fields(exclude=exclude)
def email_summary(self, owners, link):
"""Wip"""
template_vars = {
"title": "Votre résumé est prêt",
"link": link,
"room": self.slug,
}
msg_html = render_to_string("mail/html/summary.html", template_vars)
msg_plain = render_to_string("mail/text/invitation.txt", template_vars)
for owner in owners:
owner.email_user(
subject="Votre résumé est prêt",
from_email=settings.EMAIL_FROM,
message=msg_plain,
html_message=msg_html,
fail_silently=False,
)
+1 -3
View File
@@ -5,7 +5,7 @@ from django.urls import include, path
from rest_framework.routers import DefaultRouter
from core.api import get_frontend_configuration, viewsets, demo
from core.api import viewsets
from core.authentication.urls import urlpatterns as oidc_urls
# - Main endpoints
@@ -23,8 +23,6 @@ urlpatterns = [
[
*router.urls,
*oidc_urls,
path("config/", get_frontend_configuration, name="config"),
path("minio-webhook/", demo.minio_webhook, name="demo"),
]
),
),
+1 -7
View File
@@ -4,7 +4,6 @@ Utils functions used in the core app
# ruff: noqa:S311
import hashlib
import json
import random
from typing import Optional
@@ -25,11 +24,7 @@ def generate_color(identity: str) -> str:
range and ensure predictability.
"""
# ruff: noqa:S324
identity_hash = hashlib.sha1(identity.encode("utf-8"))
# Keep only hash's last 16 bits, collisions are not a concern
seed = int(identity_hash.hexdigest(), 16) & 0xFFFF
random.seed(seed)
random.seed(hash(identity))
hue = random.randint(0, 360)
saturation = random.randint(50, 75)
lightness = random.randint(25, 60)
@@ -53,7 +48,6 @@ def generate_token(room: str, user, username: Optional[str] = None) -> str:
room=room,
room_join=True,
room_admin=True,
room_record=True,
can_update_own_metadata=True,
can_publish_sources=[
"camera",
-83
View File
@@ -251,19 +251,6 @@ class Base(Configuration):
"REDOC_DIST": "SIDECAR",
}
# Frontend
FRONTEND_CONFIGURATION = {
"analytics": values.DictValue(
{}, environ_name="FRONTEND_ANALYTICS", environ_prefix=None
),
"support": values.DictValue(
{}, environ_name="FRONTEND_SUPPORT", environ_prefix=None
),
"silence_livekit_debug_logs": values.BooleanValue(
False, environ_name="FRONTEND_SILENCE_LIVEKIT_DEBUG", environ_prefix=None
),
}
# Mail
EMAIL_BACKEND = values.Value("django.core.mail.backends.smtp.EmailBackend")
EMAIL_HOST = values.Value(None)
@@ -271,7 +258,6 @@ class Base(Configuration):
EMAIL_HOST_PASSWORD = values.Value(None)
EMAIL_PORT = values.PositiveIntegerValue(None)
EMAIL_USE_TLS = values.BooleanValue(False)
EMAIL_USE_SSL = values.BooleanValue(False)
EMAIL_FROM = values.Value("from@example.com")
AUTH_USER_MODEL = "core.User"
@@ -386,53 +372,6 @@ class Base(Configuration):
None, environ_name="ANALYTICS_KEY", environ_prefix=None
)
# todo - totally wip
AWS_S3_ENDPOINT_URL = values.Value(
environ_name="AWS_S3_ENDPOINT_URL", environ_prefix=None
)
AWS_S3_ACCESS_KEY_ID = values.Value(
environ_name="AWS_S3_ACCESS_KEY_ID", environ_prefix=None
)
AWS_S3_SECRET_ACCESS_KEY = values.Value(
environ_name="AWS_S3_SECRET_ACCESS_KEY", environ_prefix=None
)
AWS_S3_REGION_NAME = values.Value(
environ_name="AWS_S3_REGION_NAME", environ_prefix=None
)
AWS_STORAGE_BUCKET_NAME = values.Value(
"meet-media-storage",
environ_name="AWS_STORAGE_BUCKET_NAME",
environ_prefix=None,
)
OPENAI_API_KEY = values.Value(
None, environ_name="OPENAI_API_KEY", environ_prefix=None
)
OPENAI_ENABLE = values.BooleanValue(
True, environ_name="OPENAI_ENABLE", environ_prefix=None
)
# todo - totally wip
MINIO_ACCESS_KEY = values.Value(
None, environ_name="MINIO_ACCESS_KEY", environ_prefix=None
)
MINIO_SECRET_KEY = values.Value(
None, environ_name="MINIO_SECRET_KEY", environ_prefix=None
)
MINIO_URL = values.Value(
None, environ_name="MINIO_URL", environ_prefix=None
)
MINIO_BUCKET = values.Value(
'livekit-staging-livekit-egress', environ_name="MINIO_BUCKET", environ_prefix=None
)
BLOCKNOTE_CONVERTER_URL = values.Value(
'https://converter-blocknote.osc-fr1.scalingo.io/', environ_name="", environ_prefix=None
)
DOCS_BASE_URL = values.Value(
'https://docs-ia.beta.numerique.gouv.fr', environ_name="", environ_prefix=None
)
# pylint: disable=invalid-name
@property
def ENVIRONMENT(self):
@@ -576,20 +515,6 @@ class Production(Base):
ALLOWED_HOSTS=["foo.com", "foo.fr"]
"""
LOGGING = {
"version": 1,
"disable_existing_loggers": False,
"handlers": {
"console": {
"class": "logging.StreamHandler",
},
},
"root": {
"handlers": ["console"],
"level": "INFO",
},
}
# Security
ALLOWED_HOSTS = [
*values.ListValue([], environ_name="ALLOWED_HOSTS"),
@@ -610,14 +535,6 @@ class Production(Base):
#
# In other cases, you should comment the following line to avoid security issues.
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
SECURE_HSTS_SECONDS = 60
SECURE_HSTS_PRELOAD = True
SECURE_HSTS_INCLUDE_SUBDOMAINS = True
SECURE_SSL_REDIRECT = True
SECURE_REDIRECT_EXEMPT = [
"^__lbheartbeat__",
"^__heartbeat__",
]
# Modern browsers require to have the `secure` attribute on cookies with `Samesite=none`
CSRF_COOKIE_SECURE = True
+12 -14
View File
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "meet"
version = "0.1.7"
version = "0.1.4"
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
classifiers = [
"Development Status :: 5 - Production/Stable",
@@ -25,7 +25,7 @@ license = { file = "LICENSE" }
readme = "README.md"
requires-python = ">=3.10"
dependencies = [
"boto3==1.35.19",
"boto3==1.35.5",
"Brotli==1.1.0",
"celery[redis]==5.4.0",
"django-configurations==2.5.1",
@@ -36,11 +36,11 @@ dependencies = [
"django-redis==5.4.0",
"django-storages[s3]==1.14.4",
"django-timezone-field>=5.1",
"django==5.1.1",
"django==5.1",
"djangorestframework==3.15.2",
"drf_spectacular==0.27.2",
"dockerflow==2024.4.2",
"easy_thumbnails==2.10",
"easy_thumbnails==2.9",
"factory_boy==3.3.1",
"freezegun==1.5.1",
"gunicorn==23.0.0",
@@ -48,18 +48,16 @@ dependencies = [
"june-analytics-python==2.3.0",
"markdown==3.7",
"nested-multipart-parser==1.5.0",
"psycopg[binary]==3.2.2",
"psycopg[binary]==3.2.1",
"PyJWT==2.9.0",
"python-frontmatter==1.1.0",
"requests==2.32.3",
"sentry-sdk==2.14.0",
"sentry-sdk==2.13.0",
"url-normalize==1.4.3",
"WeasyPrint>=60.2",
"whitenoise==6.7.0",
"mozilla-django-oidc==4.0.1",
"livekit-api==0.7.0",
"minio==7.2.9",
"openai==1.51.2"
]
[project.urls]
@@ -73,18 +71,18 @@ dev = [
"django-extensions==3.2.3",
"drf-spectacular-sidecar==2024.7.1",
"ipdb==0.13.13",
"ipython==8.27.0",
"ipython==8.26.0",
"pyfakefs==5.6.0",
"pylint-django==2.5.5",
"pylint==3.2.7",
"pylint==3.2.6",
"pytest-cov==5.0.0",
"pytest-django==4.9.0",
"pytest==8.3.3",
"pytest-django==4.8.0",
"pytest==8.3.2",
"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.6.2",
"types-requests==2.32.0.20240712",
]
[tool.setuptools]
+1 -1
View File
@@ -32,7 +32,7 @@ WORKDIR /home/frontend
RUN npm run build
# ---- Front-end image ----
FROM nginxinc/nginx-unprivileged:1.26-alpine as frontend-production
FROM nginxinc/nginx-unprivileged:1.25 as frontend-production
# Un-privileged user running the application
ARG DOCKER_USER
+1 -1
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/play-icon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Visio</title>
<title>Meet</title>
</head>
<body>
<div id="root"></div>
+2831 -2606
View File
File diff suppressed because it is too large Load Diff
+28 -33
View File
@@ -1,7 +1,7 @@
{
"name": "meet",
"private": true,
"version": "0.1.7",
"version": "0.1.4",
"type": "module",
"scripts": {
"dev": "panda codegen && vite",
@@ -13,48 +13,43 @@
"check": "prettier --check ./src"
},
"dependencies": {
"@livekit/components-react": "2.6.5",
"@livekit/components-styles": "1.1.3",
"@livekit/track-processors": "0.3.2",
"@pandacss/preset-panda": "0.46.1",
"@react-aria/toast": "3.0.0-beta.16",
"@livekit/components-react": "2.3.3",
"@livekit/components-styles": "1.0.12",
"@pandacss/preset-panda": "0.41.0",
"@remixicon/react": "4.2.0",
"@tanstack/react-query": "5.59.4",
"crisp-sdk-web": "1.0.25",
"@tanstack/react-query": "5.49.2",
"hoofd": "1.7.1",
"i18next": "23.15.2",
"i18next": "23.12.1",
"i18next-browser-languagedetector": "8.0.0",
"i18next-parser": "9.0.2",
"i18next-parser": "9.0.0",
"i18next-resources-to-backend": "1.2.1",
"livekit-client": "2.5.7",
"posthog-js": "1.167.0",
"react": "18.3.1",
"react-aria-components": "1.4.0",
"react-dom": "18.3.1",
"react-i18next": "15.0.2",
"use-sound": "4.0.3",
"valtio": "2.0.0",
"wouter": "3.3.5"
"livekit-client": "2.3.1",
"react": "18.2.0",
"react-aria-components": "1.2.1",
"react-dom": "18.2.0",
"react-i18next": "14.1.3",
"valtio": "1.13.2",
"wouter": "3.3.0"
},
"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",
"@pandacss/dev": "0.41.0",
"@tanstack/eslint-plugin-query": "5.49.1",
"@tanstack/react-query-devtools": "5.49.2",
"@types/node": "20.14.9",
"@types/react": "18.3.3",
"@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",
"@typescript-eslint/eslint-plugin": "7.13.1",
"@typescript-eslint/parser": "7.13.1",
"@vitejs/plugin-react": "4.3.1",
"eslint": "8.57.0",
"eslint-config-prettier": "9.1.0",
"eslint-plugin-jsx-a11y": "6.10.0",
"eslint-plugin-jsx-a11y": "6.9.0",
"eslint-plugin-react-hooks": "4.6.2",
"eslint-plugin-react-refresh": "0.4.12",
"postcss": "8.4.47",
"eslint-plugin-react-refresh": "0.4.7",
"postcss": "8.4.39",
"prettier": "3.3.3",
"typescript": "5.6.3",
"vite": "5.4.8",
"vite-tsconfig-paths": "5.0.1"
"typescript": "5.5.2",
"vite": "5.3.1",
"vite-tsconfig-paths": "4.3.2"
}
}
+9 -20
View File
@@ -64,27 +64,16 @@ const config: Config = {
'100%': { boxShadow: '0 0 0 0 rgba(255, 255, 255, 0)' },
},
active_speaker: {
'0%': { height: '25%' },
'25%': { height: '45%' },
'50%': { height: '20%' },
'100%': { height: '55%' },
'0%': { height: '4px' },
'25%': { height: '8px' },
'50%': { height: '6px' },
'100%': { height: '16px' },
},
active_speaker_small: {
'0%': { height: '20%' },
'25%': { height: '25%' },
'50%': { height: '18%' },
'100%': { height: '25%' },
},
wave_hand: {
'0%': { transform: 'rotate(0deg)' },
'20%': { transform: 'rotate(-20deg)' },
'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' },
active_speake_small: {
'0%': { height: '4px' },
'25%': { height: '6px' },
'50%': { height: '4px' },
'100%': { height: '8px' },
},
},
tokens: defineTokens({
Binary file not shown.
+5 -2
View File
@@ -11,15 +11,18 @@ import { Layout } from './layout/Layout'
import { NotFoundScreen } from './components/NotFoundScreen'
import { routes } from './routes'
import './i18n/init'
import { silenceLiveKitLogs } from '@/utils/livekit.ts'
import { queryClient } from '@/api/queryClient'
import { AppInitialization } from '@/components/AppInitialization'
function App() {
const { i18n } = useTranslation()
useLang(i18n.language)
const isProduction = import.meta.env.PROD
silenceLiveKitLogs(isProduction)
return (
<QueryClientProvider client={queryClient}>
<AppInitialization />
<Suspense fallback={null}>
<I18nProvider locale={i18n.language}>
<Layout>
-1
View File
@@ -1,5 +1,4 @@
export const keys = {
user: 'user',
room: 'room',
config: 'config',
}
-26
View File
@@ -1,26 +0,0 @@
import { fetchApi } from './fetchApi'
import { keys } from './queryKeys'
import { useQuery } from '@tanstack/react-query'
export interface ApiConfig {
analytics?: {
id: string
host: string
}
support?: {
id: string
}
silence_livekit_debug_logs?: boolean
}
const fetchConfig = (): Promise<ApiConfig> => {
return fetchApi<ApiConfig>(`config/`)
}
export const useConfig = () => {
return useQuery({
queryKey: [keys.config],
queryFn: fetchConfig,
staleTime: Infinity,
})
}
@@ -1,20 +0,0 @@
import { silenceLiveKitLogs } from '@/utils/livekit'
import { useConfig } from '@/api/useConfig'
import { useAnalytics } from '@/features/analytics/hooks/useAnalytics'
import { useSupport } from '@/features/support/hooks/useSupport'
export const AppInitialization = () => {
const { data } = useConfig()
const {
analytics = {},
support = {},
silence_livekit_debug_logs = false,
} = data || {}
useAnalytics(analytics)
useSupport(support)
silenceLiveKitLogs(silence_livekit_debug_logs)
return null
}
+3 -3
View File
@@ -1,12 +1,12 @@
import { Button } from '@/primitives'
import { css } from '@/styled-system/css'
import { RiExternalLinkLine } from '@remixicon/react'
import { useTranslation } from 'react-i18next'
import { LinkButton } from '@/primitives'
export const Feedback = () => {
const { t } = useTranslation()
return (
<LinkButton
<Button
href="https://grist.incubateur.net/o/docs/forms/1YrfNP1QSSy8p2gCxMFnSf/4"
variant="success"
target="_blank"
@@ -20,6 +20,6 @@ export const Feedback = () => {
className={css({ marginLeft: 0.5 })}
aria-hidden="true"
/>
</LinkButton>
</Button>
)
}
File diff suppressed because one or more lines are too long
@@ -27,17 +27,12 @@ export const SoundTester = () => {
return (
<>
<Button
invisible
onPress={() => {
audioRef?.current?.play()
setIsPlaying(true)
}}
size="sm"
isDisabled={isPlaying}
fullWidth
style={{
color: isPlaying ? 'var(--colors-primary)' : undefined,
}}
>
{isPlaying ? t('audio.speakers.ongoingTest') : t('audio.speakers.test')}
</Button>
@@ -1,39 +0,0 @@
import { useEffect } from 'react'
import { useLocation } from 'wouter'
import posthog from 'posthog-js'
import { ApiUser } from '@/features/auth/api/ApiUser'
export const startAnalyticsSession = (data: ApiUser) => {
if (posthog._isIdentified()) return
const { id, email } = data
posthog.identify(id, { email })
}
export const terminateAnalyticsSession = () => {
if (!posthog._isIdentified()) return
posthog.reset()
}
export type useAnalyticsProps = {
id?: string
host?: string
}
export const useAnalytics = ({ id, host }: useAnalyticsProps) => {
const [location] = useLocation()
useEffect(() => {
if (!id || !host) return
if (posthog.__loaded) return
posthog.init(id, {
api_host: host,
person_profiles: 'always',
})
}, [id, host])
// From PostHog tutorial on PageView tracking in a Single Page Application (SPA) context.
useEffect(() => {
posthog.capture('$pageview')
}, [location])
return null
}
@@ -20,7 +20,7 @@ export const fetchUser = (): Promise<ApiUser | false> => {
// make sure to not resolve the promise while trying to silent login
// so that consumers of fetchUser don't think the work already ended
if (canAttemptSilentLogin()) {
attemptSilentLogin(300)
attemptSilentLogin(3600)
} else {
resolve(false)
}
+2 -12
View File
@@ -2,12 +2,9 @@ import { useQuery } from '@tanstack/react-query'
import { keys } from '@/api/queryKeys'
import { fetchUser } from './fetchUser'
import { type ApiUser } from './ApiUser'
import { useEffect } from 'react'
import { startAnalyticsSession } from '@/features/analytics/hooks/useAnalytics'
import { initializeSupportSession } from '@/features/support/hooks/useSupport'
/**
* returns info about currently logged-in user
* returns info about currently logged in user
*
* `isLoggedIn` is undefined while query is loading and true/false when it's done
*/
@@ -15,16 +12,9 @@ export const useUser = () => {
const query = useQuery({
queryKey: [keys.user],
queryFn: fetchUser,
staleTime: Infinity,
staleTime: 1000 * 60 * 60, // 1 hour
})
useEffect(() => {
if (query?.data) {
startAnalyticsSession(query.data)
initializeSupportSession(query.data)
}
}, [query.data])
const isLoggedIn =
query.status === 'success' ? query.data !== false : undefined
const isLoggedOut = isLoggedIn === false
@@ -23,7 +23,7 @@ export const JoinMeetingDialog = () => {
name="roomId"
label={t('joinInputLabel')}
description={t('joinInputExample', {
example: 'https://visio.numerique.gouv.fr/azer-tyu-qsdf',
example: 'https://meet.numerique.gouv.fr/azer-tyu-qsdf',
})}
validate={(value) => {
return !isRoomValid(value.trim()) ? (
@@ -1,9 +1,8 @@
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { getRouteUrl } from '@/navigation/getRouteUrl'
import { Button, Dialog, type DialogProps, P, Text } from '@/primitives'
import { Div, Button, Dialog, Input, type DialogProps, P } from '@/primitives'
import { HStack } from '@/styled-system/jsx'
import { RiCheckLine, RiFileCopyLine, RiSpam2Fill } from '@remixicon/react'
// fixme - duplication with the InviteDialog
export const LaterMeetingDialog = ({
@@ -12,17 +11,19 @@ export const LaterMeetingDialog = ({
}: { roomId: string } & Omit<DialogProps, 'title'>) => {
const { t } = useTranslation('home')
const roomUrl = getRouteUrl('room', roomId)
const [isCopied, setIsCopied] = useState(false)
const copyLabel = t('laterMeetingDialog.copy')
const copiedLabel = t('laterMeetingDialog.copied')
const [copyLinkLabel, setCopyLinkLabel] = useState(copyLabel)
useEffect(() => {
if (isCopied) {
const timeout = setTimeout(() => setIsCopied(false), 3000)
return () => clearTimeout(timeout)
if (copyLinkLabel == copiedLabel) {
const timeout = setTimeout(() => {
setCopyLinkLabel(copyLabel)
}, 5000)
return () => {
clearTimeout(timeout)
}
}
}, [isCopied])
const [isHovered, setIsHovered] = useState(false)
}, [copyLinkLabel, copyLabel, copiedLabel])
return (
<Dialog
@@ -31,63 +32,31 @@ export const LaterMeetingDialog = ({
title={t('laterMeetingDialog.heading')}
>
<P>{t('laterMeetingDialog.description')}</P>
<Button
variant={isCopied ? 'success' : 'primary'}
size="sm"
fullWidth
aria-label={t('laterMeetingDialog.copy')}
style={{
justifyContent: 'start',
}}
onPress={() => {
navigator.clipboard.writeText(roomUrl)
setIsCopied(true)
}}
onHoverChange={setIsHovered}
data-attr="later-dialog-copy"
>
{isCopied ? (
<>
<RiCheckLine size={18} style={{ marginRight: '8px' }} />
{t('laterMeetingDialog.copied')}
</>
) : (
<>
<RiFileCopyLine
size={18}
style={{ marginRight: '8px', minWidth: '18px' }}
/>
{isHovered ? (
t('laterMeetingDialog.copy')
) : (
<div
style={{
textOverflow: 'ellipsis',
overflow: 'hidden',
userSelect: 'none',
textWrap: 'nowrap',
}}
>
{roomUrl.replace(/^https?:\/\//, '')}
</div>
)}
</>
)}
</Button>
<HStack>
<div
style={{
backgroundColor: '#d9e5ff',
borderRadius: '50%',
padding: '4px',
marginTop: '1rem',
}}
>
<RiSpam2Fill size={22} style={{ fill: '#4c84fc' }} />
</div>
<Text variant="sm" style={{ marginTop: '1rem' }}>
{t('laterMeetingDialog.permissions')}
</Text>
<HStack alignItems="stretch" gap="gutter">
<Div flex="1">
<Input
type="text"
aria-label={t('laterMeetingDialog.inputLabel')}
value={roomUrl}
readOnly
onClick={(e) => {
e.currentTarget.select()
}}
/>
</Div>
<Div minWidth="8rem">
<Button
variant="primary"
size="sm"
fullWidth
onPress={() => {
navigator.clipboard.writeText(roomUrl)
setCopyLinkLabel(copiedLabel)
}}
>
{copyLinkLabel}
</Button>
</Div>
</HStack>
</Dialog>
)
+11 -17
View File
@@ -1,18 +1,18 @@
import { useTranslation } from 'react-i18next'
import { DialogTrigger, MenuItem, Menu as RACMenu } from 'react-aria-components'
import { DialogTrigger } from 'react-aria-components'
import { Button, Menu, Text } from '@/primitives'
import { HStack } from '@/styled-system/jsx'
import { navigateTo } from '@/navigation/navigateTo'
import { Screen } from '@/layout/Screen'
import { Centered } from '@/layout/Centered'
import { generateRoomId } from '@/features/rooms'
import { useUser, UserAware } from '@/features/auth'
import { authUrl, useUser, UserAware } from '@/features/auth'
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 { MenuItem, Menu as RACMenu } from 'react-aria-components'
import { LaterMeetingDialog } from '@/features/home/components/LaterMeetingDialog'
import { useState } from 'react'
@@ -27,6 +27,8 @@ export const Home = () => {
const { mutateAsync: createRoom } = useCreateRoom()
const [laterRoomId, setLaterRoomId] = useState<null | string>(null)
console.log(laterRoomId)
return (
<UserAware>
<Screen>
@@ -42,12 +44,10 @@ export const Home = () => {
{t('loginToCreateMeeting')}
</Text>
)}
<HStack gap="gutter" alignItems="start">
<HStack gap="gutter">
{isLoggedIn ? (
<Menu>
<Button variant="primary" data-attr="create-meeting">
{t('createMeeting')}
</Button>
<Button variant="primary">{t('createMeeting')}</Button>
<RACMenu>
<MenuItem
className={menuItemRecipe({ icon: true })}
@@ -59,7 +59,6 @@ export const Home = () => {
})
)
}}
data-attr="create-option-instant"
>
<RiAddLine size={18} />
{t('createMenu.instantOption')}
@@ -72,7 +71,6 @@ export const Home = () => {
setLaterRoomId(data.slug)
)
}}
data-attr="create-option-later"
>
<RiLink size={18} />
{t('createMenu.laterOption')}
@@ -80,16 +78,12 @@ export const Home = () => {
</RACMenu>
</Menu>
) : (
<ProConnectButton />
<Button variant="primary" href={authUrl()}>
{t('login', { ns: 'global' })}
</Button>
)}
<DialogTrigger>
<Button
variant="primary"
outline
style={{
height: !isLoggedIn ? '56px' : undefined, // Temporary, Align with ProConnect Button fixed height
}}
>
<Button variant="primary" outline>
{t('joinMeeting')}
</Button>
<JoinMeetingDialog />
@@ -1,112 +0,0 @@
import { useEffect } from 'react'
import { useRoomContext } from '@livekit/components-react'
import { Participant, RoomEvent } from 'livekit-client'
import { ToastProvider, toastQueue } from './components/ToastProvider'
import { NotificationType } from './NotificationType'
import { Div } from '@/primitives'
import { isMobileBrowser } from '@livekit/components-core'
import { useNotificationSound } from '@/features/notifications/hooks/useSoundNotification'
export const MainNotificationToast = () => {
const room = useRoomContext()
const { triggerNotificationSound } = useNotificationSound()
useEffect(() => {
const showJoinNotification = (participant: Participant) => {
if (isMobileBrowser()) {
return
}
triggerNotificationSound(NotificationType.Joined)
toastQueue.add(
{
participant,
type: NotificationType.Joined,
},
{
timeout: 5000,
}
)
}
room.on(RoomEvent.ParticipantConnected, showJoinNotification)
return () => {
room.off(RoomEvent.ParticipantConnected, showJoinNotification)
}
}, [room, triggerNotificationSound])
useEffect(() => {
const removeParticipantNotifications = (participant: Participant) => {
toastQueue.visibleToasts.forEach((toast) => {
if (toast.content.participant === participant) {
toastQueue.close(toast.key)
}
})
}
room.on(RoomEvent.ParticipantDisconnected, removeParticipantNotifications)
return () => {
room.off(
RoomEvent.ParticipantDisconnected,
removeParticipantNotifications
)
}
}, [room])
useEffect(() => {
const handleNotificationReceived = (
prevMetadataStr: string | undefined,
participant: Participant
) => {
if (!participant) return
if (isMobileBrowser()) return
if (participant.isLocal) return
const prevMetadata = JSON.parse(prevMetadataStr || '{}')
const metadata = JSON.parse(participant.metadata || '{}')
if (prevMetadata.raised == metadata.raised) return
const existingToast = toastQueue.visibleToasts.find(
(toast) =>
toast.content.participant === participant &&
toast.content.type === NotificationType.Raised
)
if (existingToast && prevMetadata.raised && !metadata.raised) {
toastQueue.close(existingToast.key)
return
}
if (!existingToast && !prevMetadata.raised && metadata.raised) {
triggerNotificationSound(NotificationType.Raised)
toastQueue.add(
{
participant,
type: NotificationType.Raised,
},
{ timeout: 5000 }
)
}
}
room.on(RoomEvent.ParticipantMetadataChanged, handleNotificationReceived)
return () => {
room.off(RoomEvent.ParticipantMetadataChanged, handleNotificationReceived)
}
}, [room, triggerNotificationSound])
useEffect(() => {
const closeAllToasts = () => {
toastQueue.visibleToasts.forEach(({ key }) => toastQueue.close(key))
}
room.on(RoomEvent.Disconnected, closeAllToasts)
return () => {
room.off(RoomEvent.Disconnected, closeAllToasts)
}
}, [room])
return (
<Div position="absolute" bottom={0} right={5} zIndex={1000}>
<ToastProvider />
</Div>
)
}
@@ -1,6 +0,0 @@
export enum NotificationType {
Joined = 'joined',
Default = 'default',
Raised = 'raised',
Lowered = 'lowered',
}
@@ -1,58 +0,0 @@
import { useToast } from '@react-aria/toast'
import { Button } from '@/primitives'
import { RiCloseLine } from '@remixicon/react'
import { ToastState } from '@react-stately/toast'
import { styled } from '@/styled-system/jsx'
import { useRef } from 'react'
import { ToastData } from './ToastProvider'
import type { QueuedToast } from '@react-stately/toast'
export const StyledToastContainer = styled('div', {
base: {
margin: 0.5,
boxShadow:
'rgba(0, 0, 0, 0.5) 0px 4px 8px 0px, rgba(0, 0, 0, 0.3) 0px 6px 20px 4px',
backgroundColor: '#494c4f',
color: 'white',
borderRadius: '8px',
'&[data-entering]': { animation: 'fade 200ms' },
'&[data-exiting]': { animation: 'fade 150ms reverse ease-in' },
width: 'fit-content',
marginLeft: 'auto',
},
})
const StyledToast = styled('div', {
base: {
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
gap: '1rem',
padding: '10px',
},
})
export interface ToastProps {
key: string
toast: QueuedToast<ToastData>
state: ToastState<ToastData>
}
export function Toast({ state, ...props }: ToastProps) {
const ref = useRef(null)
const { toastProps, contentProps, closeButtonProps } = useToast(
props,
state,
ref
)
return (
<StyledToastContainer {...toastProps} ref={ref}>
<StyledToast>
<div {...contentProps}>{props.toast.content?.message} machine a</div>
<Button square size="sm" invisible {...closeButtonProps}>
<RiCloseLine color="white" />
</Button>
</StyledToast>
</StyledToastContainer>
)
}
@@ -1,73 +0,0 @@
import { useToast } from '@react-aria/toast'
import { useRef } from 'react'
import { Button as RACButton } from 'react-aria-components'
import { Track } from 'livekit-client'
import Source = Track.Source
import { useMaybeLayoutContext } from '@livekit/components-react'
import { ParticipantTile } from '@/features/rooms/livekit/components/ParticipantTile'
import { StyledToastContainer, ToastProps } from './Toast'
import { HStack, styled } from '@/styled-system/jsx'
import { Div } from '@/primitives'
import { useTranslation } from 'react-i18next'
const ClickableToast = styled(RACButton, {
base: {
cursor: 'pointer',
display: 'flex',
borderRadius: 'inherit',
},
})
export function ToastJoined({ state, ...props }: ToastProps) {
const { t } = useTranslation('notifications')
const ref = useRef(null)
const { toastProps, contentProps, titleProps, closeButtonProps } = useToast(
props,
state,
ref
)
const layoutContext = useMaybeLayoutContext()
const participant = props.toast.content.participant
const trackReference = {
participant,
publication: participant.getTrackPublication(Source.Camera),
source: Source.Camera,
}
const pinParticipant = () => {
layoutContext?.pin.dispatch?.({
msg: 'set_pin',
trackReference,
})
}
return (
<StyledToastContainer {...toastProps} ref={ref}>
<ClickableToast
ref={ref}
onPress={(e) => {
pinParticipant()
closeButtonProps.onPress?.(e)
}}
>
<HStack justify="center" alignItems="center" {...contentProps}>
<Div display="flex" overflow="hidden" width="128" height="72">
<ParticipantTile
trackRef={trackReference}
disableSpeakingIndicator={true}
disableMetadata={true}
style={{
borderRadius: '7px 0 0 7px',
width: '100%',
}}
/>
</Div>
<Div padding={20} {...titleProps}>
{t('joined.description', {
name: participant.name || t('defaultName'),
})}
</Div>
</HStack>
</ClickableToast>
</StyledToastContainer>
)
}
@@ -1,28 +0,0 @@
/* eslint-disable react-refresh/only-export-components */
import { ToastQueue, useToastQueue } from '@react-stately/toast'
import { ToastRegion } from './ToastRegion'
import { Participant } from 'livekit-client'
import { NotificationType } from '../NotificationType'
export interface ToastData {
participant: Participant
type: NotificationType
message?: string
}
// Using a global queue for toasts allows for centralized management and queuing of notifications
// from anywhere in the app, providing greater flexibility in complex scenarios.
export const toastQueue = new ToastQueue<ToastData>({
maxVisibleToasts: 5,
})
export const ToastProvider = ({ ...props }) => {
const state = useToastQueue<ToastData>(toastQueue)
return (
<>
{state.visibleToasts.length > 0 && (
<ToastRegion {...props} state={state} />
)}
</>
)
}
@@ -1,66 +0,0 @@
import { useToast } from '@react-aria/toast'
import { useRef } from 'react'
import { StyledToastContainer, ToastProps } from './Toast'
import { HStack } from '@/styled-system/jsx'
import { Button, Div } from '@/primitives'
import { useTranslation } from 'react-i18next'
import { RiCloseLine, RiHand } from '@remixicon/react'
import { useSidePanel } from '@/features/rooms/livekit/hooks/useSidePanel'
export function ToastRaised({ state, ...props }: ToastProps) {
const { t } = useTranslation('notifications')
const ref = useRef(null)
const { toastProps, contentProps, titleProps, closeButtonProps } = useToast(
props,
state,
ref
)
const participant = props.toast.content.participant
const { isParticipantsOpen, toggleParticipants } = useSidePanel()
return (
<StyledToastContainer {...toastProps} ref={ref}>
<HStack
justify="center"
alignItems="center"
{...contentProps}
padding={14}
gap={0}
>
<RiHand
color="white"
style={{
marginRight: '1rem',
animationDuration: '300ms',
animationName: 'wave_hand',
animationIterationCount: '2',
}}
/>
<Div {...titleProps} marginRight={0.5}>
{t('raised.description', {
name: participant.name || t('defaultName'),
})}
</Div>
{!isParticipantsOpen && (
<Button
size="sm"
variant="text"
style={{
color: '#60a5fa',
}}
onPress={(e) => {
toggleParticipants()
closeButtonProps.onPress?.(e)
}}
>
{t('raised.cta')}
</Button>
)}
<Button square size="sm" invisible {...closeButtonProps}>
<RiCloseLine size={18} color="white" />
</Button>
</HStack>
</StyledToastContainer>
)
}
@@ -1,30 +0,0 @@
import { AriaToastRegionProps, useToastRegion } from '@react-aria/toast'
import type { ToastState } from '@react-stately/toast'
import { Toast } from './Toast'
import { useRef } from 'react'
import { NotificationType } from '../NotificationType'
import { ToastJoined } from './ToastJoined'
import { ToastData } from './ToastProvider'
import { ToastRaised } from '@/features/notifications/components/ToastRaised.tsx'
interface ToastRegionProps extends AriaToastRegionProps {
state: ToastState<ToastData>
}
export function ToastRegion({ state, ...props }: ToastRegionProps) {
const ref = useRef(null)
const { regionProps } = useToastRegion(props, state, ref)
return (
<div {...regionProps} ref={ref} className="toast-region">
{state.visibleToasts.map((toast) => {
if (toast.content?.type === NotificationType.Joined) {
return <ToastJoined key={toast.key} toast={toast} state={state} />
}
if (toast.content?.type === NotificationType.Raised) {
return <ToastRaised key={toast.key} toast={toast} state={state} />
}
return <Toast key={toast.key} toast={toast} state={state} />
})}
</div>
)
}
@@ -1,18 +0,0 @@
import useSound from 'use-sound'
// fixme - handle dynamic audio output changes
export const useNotificationSound = () => {
const [play] = useSound('./sounds/notifications.mp3', {
sprite: {
joined: [0, 1150],
raised: [1400, 180],
message: [1580, 300],
waiting: [2039, 710],
success: [2740, 1304],
},
})
const triggerNotificationSound = (type: string) => {
play({ id: type })
}
return { triggerNotificationSound }
}
@@ -12,19 +12,6 @@ const StyledContainer = styled('div', {
justifyContent: 'center',
gap: '2px',
},
variants: {
pushToTalk: {
true: {
height: '46px',
width: '58px',
borderLeftRadius: 8,
borderRightRadius: 0,
backgroundColor: '#dbeafe',
border: '1px solid #3b82f6',
gap: '3px',
},
},
},
})
const StyledChild = styled('div', {
@@ -49,32 +36,16 @@ const StyledChild = styled('div', {
},
size: {
small: {
animationName: 'active_speaker_small',
},
},
pushToTalk: {
true: {
backgroundColor: 'primary',
width: '6px',
height: '6px',
animationName: 'active_speake_small',
},
},
},
})
export type ActiveSpeakerProps = {
isSpeaking: boolean
pushToTalk?: boolean
}
export const ActiveSpeaker = ({
isSpeaking,
pushToTalk,
}: ActiveSpeakerProps) => {
export const ActiveSpeaker = ({ isSpeaking }: { isSpeaking: boolean }) => {
return (
<StyledContainer pushToTalk={pushToTalk}>
<StyledContainer>
<StyledChild
pushToTalk={pushToTalk}
active={isSpeaking}
size="small"
style={{
@@ -82,14 +53,12 @@ export const ActiveSpeaker = ({
}}
/>
<StyledChild
pushToTalk={pushToTalk}
active={isSpeaking}
style={{
animationDelay: '100ms',
}}
/>
<StyledChild
pushToTalk={pushToTalk}
active={isSpeaking}
size="small"
style={{
@@ -1,10 +1,15 @@
import { useEffect, useMemo, useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import { LiveKitRoom, type LocalUserChoices } from '@livekit/components-react'
import {
formatChatMessageLinks,
LiveKitRoom,
type LocalUserChoices,
} from '@livekit/components-react'
import { Room, RoomOptions } from 'livekit-client'
import { keys } from '@/api/queryKeys'
import { queryClient } from '@/api/queryClient'
import { navigateTo } from '@/navigation/navigateTo'
import { Screen } from '@/layout/Screen'
import { QueryAware } from '@/components/QueryAware'
import { ErrorScreen } from '@/components/ErrorScreen'
@@ -14,7 +19,6 @@ import { useCreateRoom } from '../api/createRoom'
import { InviteDialog } from './InviteDialog'
import { VideoConference } from '../livekit/prefabs/VideoConference'
import posthog from 'posthog-js'
export const Conference = ({
roomId,
@@ -27,9 +31,6 @@ export const Conference = ({
mode?: 'join' | 'create'
initialRoomData?: ApiRoom
}) => {
useEffect(() => {
posthog.capture('visit-room', { slug: roomId })
}, [roomId])
const fetchKey = [keys.room, roomId, userConfig.username]
const {
@@ -49,6 +50,7 @@ export const Conference = ({
} = useQuery({
queryKey: fetchKey,
staleTime: 6 * 60 * 60 * 1000, // By default, LiveKit access tokens expire 6 hours after generation
enabled: !initialRoomData,
initialData: initialRoomData,
queryFn: () =>
fetchRoom({
@@ -78,6 +80,25 @@ export const Conference = ({
const [showInviteDialog, setShowInviteDialog] = useState(mode === 'create')
/**
* checks for actual click on the leave button instead of
* relying on LiveKitRoom onDisconnected because onDisconnected
* triggers even on page reload, it's not a user "onLeave" event really.
* Here we want to react to the user actually deciding to leave.
*/
useEffect(() => {
const checkOnLeaveClick = (event: MouseEvent) => {
const target = event.target as HTMLElement
if (target.classList.contains('lk-disconnect-button')) {
navigateTo('feedback')
}
}
document.body.addEventListener('click', checkOnLeaveClick)
return () => {
document.body.removeEventListener('click', checkOnLeaveClick)
}
}, [])
const { t } = useTranslation('rooms')
if (isCreateError) {
// this error screen should be replaced by a proper waiting room for anonymous user.
@@ -100,7 +121,7 @@ export const Conference = ({
audio={userConfig.audioEnabled}
video={userConfig.videoEnabled}
>
<VideoConference />
<VideoConference chatMessageFormatter={formatChatMessageLinks} />
{showInviteDialog && (
<InviteDialog
isOpen={showInviteDialog}
@@ -4,20 +4,14 @@ import { Div, Button, type DialogProps, P } from '@/primitives'
import { HStack, styled, VStack } from '@/styled-system/jsx'
import { Heading, Dialog } from 'react-aria-components'
import { Text, text } from '@/primitives/Text'
import {
RiCheckLine,
RiCloseLine,
RiFileCopyLine,
RiSpam2Fill,
} from '@remixicon/react'
import { useEffect, useState } from 'react'
import { RiCloseLine, RiFileCopyLine, RiSpam2Fill } from '@remixicon/react'
// fixme - extract in a proper primitive this dialog without overlay
const StyledRACDialog = styled(Dialog, {
base: {
position: 'fixed',
left: '0.75rem',
bottom: 80,
left: 30,
bottom: 90,
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
@@ -40,25 +34,17 @@ export const InviteDialog = ({
const { t } = useTranslation('rooms')
const roomUrl = getRouteUrl('room', roomId)
const [isCopied, setIsCopied] = useState(false)
useEffect(() => {
if (isCopied) {
const timeout = setTimeout(() => setIsCopied(false), 3000)
return () => clearTimeout(timeout)
}
}, [isCopied])
const [isHovered, setIsHovered] = useState(false)
return (
<StyledRACDialog {...dialogProps}>
{({ close }) => (
<VStack
alignItems="left"
alignItems={'left'}
justify="start"
gap={0}
style={{ maxWidth: '100%', overflow: 'hidden' }}
style={{
maxWidth: '100%',
overflow: 'hidden',
}}
>
<Heading slot="title" level={3} className={text({ variant: 'h2' })}>
{t('shareDialog.heading')}
@@ -77,49 +63,36 @@ export const InviteDialog = ({
</Button>
</Div>
<P>{t('shareDialog.description')}</P>
<Button
variant={isCopied ? 'success' : 'primary'}
size="sm"
fullWidth
aria-label={t('shareDialog.copy')}
<HStack
justify={'space-between'}
alignItems="center"
style={{
justifyContent: 'start',
backgroundColor: '#f1f3f4',
borderRadius: '4px',
maxWidth: '100%',
}}
onPress={() => {
navigator.clipboard.writeText(roomUrl)
setIsCopied(true)
}}
onHoverChange={setIsHovered}
data-attr="share-dialog-copy"
gap={0}
>
{isCopied ? (
<>
<RiCheckLine size={18} 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>
)}
</>
)}
</Button>
<div
style={{
paddingLeft: '0.75rem',
textOverflow: 'ellipsis',
overflow: 'hidden',
textWrap: 'nowrap',
userSelect: 'none',
}}
>
{roomUrl.replace(/^https?:\/\//, '')}
</div>
<Button
square
invisible
tooltip={t('shareDialog.copy')}
onPress={() => navigator.clipboard.writeText(roomUrl)}
>
<RiFileCopyLine size={24} />
</Button>
</HStack>
<HStack>
<div
style={{
@@ -16,8 +16,8 @@ export const Join = ({
<PreJoin
persistUserChoices
onSubmit={onSubmit}
micLabel={t('join.audioinput.label')}
camLabel={t('join.videoinput.label')}
micLabel={t('join.micLabel')}
camLabel={t('join.camlabel')}
joinLabel={t('join.joinLabel')}
userLabel={t('join.userLabel')}
/>
@@ -1,53 +0,0 @@
import { useRoomContext } from '@livekit/components-react'
import { useEffect, useState } from 'react'
import { RoomEvent } from 'livekit-client'
import { egressStore } from '@/stores/egress.ts'
import { useSnapshot } from 'valtio'
export const RecordingIndicator = () => {
const room = useRoomContext()
const [isRecording, setIsRecording] = useState(room.isRecording)
const egressSnap = useSnapshot(egressStore)
const egressIsStopping = egressSnap.egressIsStopping
useEffect(() => {
const handleRecordingStatusChanges = (isRecording: boolean) => {
if (!isRecording) {
egressStore.egressIsStopping = false
}
setIsRecording(isRecording)
}
room.on(RoomEvent.RecordingStatusChanged, handleRecordingStatusChanges)
return () => {
room.off(RoomEvent.RecordingStatusChanged, handleRecordingStatusChanges)
}
}, [room])
const getStatus = () => {
if (egressIsStopping) {
return 'saving recording'
}
if (isRecording) {
return 'recording'
}
if (!isRecording) {
return 'available'
}
}
return (
<div
style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
flexDirection: 'column',
width: '100%',
}}
>
Room status: {getStatus()}
</div>
)
}
@@ -1,33 +0,0 @@
import { Participant } from 'livekit-client'
import { fetchServerApi } from './fetchServerApi'
import { buildServerApiUrl } from './buildServerApiUrl'
import { useRoomData } from '../hooks/useRoomData'
export const useLowerHandParticipant = () => {
const data = useRoomData()
const lowerHandParticipant = (participant: Participant) => {
if (!data || !data?.livekit) {
throw new Error('Room data is not available')
}
const newMetadata = JSON.parse(participant.metadata || '{}')
newMetadata.raised = !newMetadata.raised
return fetchServerApi(
buildServerApiUrl(
data.livekit.url,
'twirp/livekit.RoomService/UpdateParticipant'
),
data.livekit.token,
{
method: 'POST',
body: JSON.stringify({
room: data.livekit.room,
identity: participant.identity,
metadata: JSON.stringify(newMetadata),
permission: participant.permissions,
}),
}
)
}
return { lowerHandParticipant }
}
@@ -1,19 +0,0 @@
import { Participant } from 'livekit-client'
import { useLowerHandParticipant } from '@/features/rooms/livekit/api/lowerHandParticipant'
export const useLowerHandParticipants = () => {
const { lowerHandParticipant } = useLowerHandParticipant()
const lowerHandParticipants = (participants: Array<Participant>) => {
try {
const promises = participants.map((participant) =>
lowerHandParticipant(participant)
)
return Promise.all(promises)
} catch (error) {
console.error('An error occurred while lowering hands :', error)
throw new Error('An error occurred while lowering hands.')
}
}
return { lowerHandParticipants }
}
@@ -1,56 +0,0 @@
import { fetchServerApi } from './fetchServerApi'
import { buildServerApiUrl } from './buildServerApiUrl'
import { useRoomData } from '../hooks/useRoomData'
import { useParams } from 'wouter'
export const useRecordRoom = () => {
const data = useRoomData()
const { roomId: roomSlug } = useParams()
const recordRoom = () => {
if (!data || !data?.livekit) {
throw new Error('Room data is not available')
}
if (!roomSlug) {
throw new Error('Room ID is not available')
}
return fetchServerApi(
buildServerApiUrl(
data.livekit.url,
'/twirp/livekit.Egress/StartRoomCompositeEgress'
),
data.livekit.token,
{
method: 'POST',
body: JSON.stringify({
room_name: data.livekit.room,
audio_only: true,
file_outputs: [
{
file_extension: 'ogg',
filepath: `{room_name}_{time}_${roomSlug}`,
},
],
}),
}
)
}
const stopRecordingRoom = (egressId: string) => {
if (!data || !data?.livekit) {
throw new Error('Room data is not available')
}
return fetchServerApi(
buildServerApiUrl(data.livekit.url, '/twirp/livekit.Egress/StopEgress'),
data.livekit.token,
{
method: 'POST',
body: JSON.stringify({
egressId,
}),
}
)
}
return { recordRoom, stopRecordingRoom }
}
@@ -1,191 +0,0 @@
import { useEffect, useRef, useState } from 'react'
import { useLocalParticipant } from '@livekit/components-react'
import { LocalVideoTrack } from 'livekit-client'
import { Text, P, ToggleButton, Div, H } from '@/primitives'
import { useTranslation } from 'react-i18next'
import { HStack, styled, VStack } from '@/styled-system/jsx'
import {
BackgroundBlur,
BackgroundOptions,
ProcessorWrapper,
BackgroundTransformer,
} from '@livekit/track-processors'
const Information = styled('div', {
base: {
backgroundColor: 'orange.50',
borderRadius: '4px',
padding: '0.75rem 0.75rem',
marginTop: '0.8rem',
alignItems: 'start',
},
})
enum BlurRadius {
NONE = 0,
LIGHT = 5,
NORMAL = 10,
}
export const Effects = () => {
const { t } = useTranslation('rooms', { keyPrefix: 'effects' })
const { isCameraEnabled, cameraTrack, localParticipant } =
useLocalParticipant()
const videoRef = useRef<HTMLVideoElement>(null)
const [processorPending, setProcessorPending] = useState(false)
const localCameraTrack = cameraTrack?.track as LocalVideoTrack
const getProcessor = () => {
return localCameraTrack?.getProcessor() as ProcessorWrapper<BackgroundOptions>
}
const getBlurRadius = (): BlurRadius => {
const processor = getProcessor()
return (
(processor?.transformer as BackgroundTransformer)?.options?.blurRadius ||
BlurRadius.NONE
)
}
const toggleBlur = async (blurRadius: number) => {
if (!isCameraEnabled) await localParticipant.setCameraEnabled(true)
if (!localCameraTrack) return
setProcessorPending(true)
const processor = getProcessor()
const currentBlurRadius = getBlurRadius()
try {
if (blurRadius == currentBlurRadius && processor) {
await localCameraTrack.stopProcessor()
} else if (!processor) {
await localCameraTrack.setProcessor(BackgroundBlur(blurRadius))
} else {
await processor?.updateTransformerOptions({ blurRadius })
}
} catch (error) {
console.error('Error applying blur:', error)
} finally {
setProcessorPending(false)
}
}
useEffect(() => {
const videoElement = videoRef.current
if (!videoElement) return
const attachVideoTrack = async () => localCameraTrack?.attach(videoElement)
attachVideoTrack()
return () => {
if (!videoElement) return
localCameraTrack.detach(videoElement)
}
}, [localCameraTrack, isCameraEnabled])
const isSelected = (blurRadius: BlurRadius) => {
return isCameraEnabled && getBlurRadius() == blurRadius
}
const tooltipLabel = (blurRadius: BlurRadius) => {
return t(`blur.${isSelected(blurRadius) ? 'clear' : 'apply'}`)
}
return (
<VStack padding="0 1.5rem" overflowY="scroll">
{localCameraTrack && isCameraEnabled ? (
<video
ref={videoRef}
width="100%"
muted
style={{
transform: 'rotateY(180deg)',
minHeight: '175px',
borderRadius: '8px',
}}
/>
) : (
<div
style={{
width: '100%',
height: '174px',
display: 'flex',
backgroundColor: 'black',
justifyContent: 'center',
flexDirection: 'column',
}}
>
<P
style={{
color: 'white',
textAlign: 'center',
textWrap: 'balance',
marginBottom: 0,
}}
>
{t('activateCamera')}
</P>
</div>
)}
<Div
alignItems={'left'}
width={'100%'}
style={{
border: '1px solid #dadce0',
borderRadius: '8px',
margin: '0 .625rem',
padding: '0.5rem 1rem',
}}
>
<H
lvl={3}
style={{
marginBottom: '0.4rem',
}}
>
{t('heading')}
</H>
{ProcessorWrapper.isSupported ? (
<HStack>
<ToggleButton
size={'sm'}
legacyStyle
aria-label={tooltipLabel(BlurRadius.LIGHT)}
tooltip={tooltipLabel(BlurRadius.LIGHT)}
isDisabled={processorPending}
onPress={async () => await toggleBlur(BlurRadius.LIGHT)}
isSelected={isSelected(BlurRadius.LIGHT)}
>
{t('blur.light')}
</ToggleButton>
<ToggleButton
size={'sm'}
legacyStyle
aria-label={tooltipLabel(BlurRadius.NORMAL)}
tooltip={tooltipLabel(BlurRadius.NORMAL)}
isDisabled={processorPending}
onPress={async () => await toggleBlur(BlurRadius.NORMAL)}
isSelected={isSelected(BlurRadius.NORMAL)}
>
{t('blur.normal')}
</ToggleButton>
</HStack>
) : (
<Text variant="sm">{t('notAvailable')}</Text>
)}
<Information>
<Text
variant="sm"
style={{
textWrap: 'balance',
}}
>
{t('experimental')}
</Text>
</Information>
</Div>
</VStack>
)
}
@@ -1,26 +0,0 @@
import { useTrackMutedIndicator } from '@livekit/components-react'
import { Participant, Track } from 'livekit-client'
import Source = Track.Source
import { Div } from '@/primitives'
import { RiMicOffFill } from '@remixicon/react'
export const MutedMicIndicator = ({
participant,
}: {
participant: Participant
}) => {
const { isMuted } = useTrackMutedIndicator({
participant: participant,
source: Source.Microphone,
})
if (!isMuted) {
return null
}
return (
<Div padding={0.25} backgroundColor="red" borderRadius="4px">
<RiMicOffFill size={16} color="white" />
</Div>
)
}
@@ -6,6 +6,7 @@ import {
ParticipantName,
ParticipantTileProps,
ScreenShareIcon,
TrackMutedIndicator,
useEnsureTrackRef,
useFeatureContext,
useIsEncrypted,
@@ -24,10 +25,6 @@ import {
} from '@livekit/components-core'
import { Track } from 'livekit-client'
import { ParticipantPlaceholder } from '@/features/rooms/livekit/components/ParticipantPlaceholder'
import { RiHand } from '@remixicon/react'
import { useRaisedHand } from '@/features/rooms/livekit/hooks/useRaisedHand'
import { HStack } from '@/styled-system/jsx'
import { MutedMicIndicator } from '@/features/rooms/livekit/components/MutedMicIndicator'
export function TrackRefContextIfNeeded(
props: React.PropsWithChildren<{
@@ -44,24 +41,19 @@ export function TrackRefContextIfNeeded(
)
}
interface ParticipantTileExtendedProps extends ParticipantTileProps {
disableMetadata?: boolean
}
export const ParticipantTile: (
props: ParticipantTileExtendedProps & React.RefAttributes<HTMLDivElement>
props: ParticipantTileProps & React.RefAttributes<HTMLDivElement>
) => React.ReactNode = /* @__PURE__ */ React.forwardRef<
HTMLDivElement,
ParticipantTileExtendedProps
ParticipantTileProps
>(function ParticipantTile(
{
trackRef,
children,
onParticipantClick,
disableSpeakingIndicator,
disableMetadata,
...htmlProps
}: ParticipantTileExtendedProps,
}: ParticipantTileProps,
ref
) {
const trackReference = useEnsureTrackRef(trackRef)
@@ -92,10 +84,6 @@ export const ParticipantTile: (
[trackReference, layoutContext]
)
const { isHandRaised } = useRaisedHand({
participant: trackReference.participant,
})
return (
<div ref={ref} style={{ position: 'relative' }} {...elementProps}>
<TrackRefContextIfNeeded trackRef={trackReference}>
@@ -124,56 +112,34 @@ export const ParticipantTile: (
participant={trackReference.participant}
/>
</div>
{!disableMetadata && (
<div className="lk-participant-metadata">
<HStack gap={0.25}>
<MutedMicIndicator
participant={trackReference.participant}
/>
<div
className="lk-participant-metadata-item"
style={{
minHeight: '24px',
backgroundColor: isHandRaised ? 'white' : undefined,
color: isHandRaised ? 'black' : undefined,
transition: 'background 200ms ease, color 400ms ease',
}}
>
{trackReference.source === Track.Source.Camera ? (
<>
{isHandRaised && (
<RiHand
color="black"
size={16}
style={{
marginInlineEnd: '.25rem', // fixme - match TrackMutedIndicator styling
animationDuration: '300ms',
animationName: 'wave_hand',
animationIterationCount: '2',
}}
/>
)}
{isEncrypted && (
<LockLockedIcon
style={{ marginRight: '0.25rem' }}
/>
)}
<ParticipantName />
</>
) : (
<>
<ScreenShareIcon style={{ marginRight: '0.25rem' }} />
<ParticipantName>&apos;s screen</ParticipantName>
</>
<div className="lk-participant-metadata">
<div className="lk-participant-metadata-item">
{trackReference.source === Track.Source.Camera ? (
<>
{isEncrypted && (
<LockLockedIcon style={{ marginRight: '0.25rem' }} />
)}
</div>
</HStack>
<ConnectionQualityIndicator className="lk-participant-metadata-item" />
<TrackMutedIndicator
trackRef={{
participant: trackReference.participant,
source: Track.Source.Microphone,
}}
show={'muted'}
></TrackMutedIndicator>
<ParticipantName />
</>
) : (
<>
<ScreenShareIcon style={{ marginRight: '0.25rem' }} />
<ParticipantName>&apos;s screen</ParticipantName>
</>
)}
</div>
)}
<ConnectionQualityIndicator className="lk-participant-metadata-item" />
</div>
</>
)}
{!disableMetadata && <FocusToggle trackRef={trackReference} />}
<FocusToggle trackRef={trackReference} />
</ParticipantContextIfNeeded>
</TrackRefContextIfNeeded>
</div>
@@ -1,131 +0,0 @@
import { layoutStore } from '@/stores/layout'
import { css } from '@/styled-system/css'
import { Heading } from 'react-aria-components'
import { text } from '@/primitives/Text'
import { Box, Button, Div } from '@/primitives'
import { RiCloseLine } from '@remixicon/react'
import { useTranslation } from 'react-i18next'
import { ParticipantsList } from './controls/Participants/ParticipantsList'
import { useSidePanel } from '../hooks/useSidePanel'
import { ReactNode } from 'react'
import { Effects } from './Effects'
import { Chat } from '../prefabs/Chat'
type StyledSidePanelProps = {
title: string
children: ReactNode
onClose: () => void
isClosed: boolean
closeButtonTooltip: string
}
const StyledSidePanel = ({
title,
children,
onClose,
isClosed,
closeButtonTooltip,
}: StyledSidePanelProps) => (
<Box
size="sm"
className={css({
overflow: 'hidden',
display: 'flex',
flexDirection: 'column',
margin: '1.5rem 1.5rem 1.5rem 0',
padding: 0,
gap: 0,
right: 0,
top: 0,
bottom: '80px',
width: '360px',
position: 'absolute',
transition: '.5s cubic-bezier(.4,0,.2,1) 5ms',
})}
style={{
transform: isClosed ? 'translateX(calc(360px + 1.5rem))' : 'none',
}}
>
<Heading
slot="title"
level={1}
className={text({ variant: 'h2' })}
style={{
paddingLeft: '1.5rem',
paddingTop: '1rem',
display: isClosed ? 'none' : undefined,
}}
>
{title}
</Heading>
<Div
position="absolute"
top="5"
right="5"
style={{
display: isClosed ? 'none' : undefined,
}}
>
<Button
invisible
size="xs"
onPress={onClose}
aria-label={closeButtonTooltip}
tooltip={closeButtonTooltip}
>
<RiCloseLine />
</Button>
</Div>
{children}
</Box>
)
type PanelProps = {
isOpen: boolean
children: React.ReactNode
}
const Panel = ({ isOpen, children }: PanelProps) => (
<div
style={{
display: isOpen ? 'inherit' : 'none',
flexDirection: 'column',
overflow: 'hidden',
flexGrow: 1,
}}
>
{children}
</div>
)
export const SidePanel = () => {
const {
activePanelId,
isParticipantsOpen,
isEffectsOpen,
isChatOpen,
isSidePanelOpen,
} = useSidePanel()
const { t } = useTranslation('rooms', { keyPrefix: 'sidePanel' })
return (
<StyledSidePanel
title={t(`heading.${activePanelId}`)}
onClose={() => (layoutStore.activePanelId = null)}
closeButtonTooltip={t('closeButton', {
content: t(`content.${activePanelId}`),
})}
isClosed={!isSidePanelOpen}
>
<Panel isOpen={isParticipantsOpen}>
<ParticipantsList />
</Panel>
<Panel isOpen={isEffectsOpen}>
<Effects />
</Panel>
<Panel isOpen={isChatOpen}>
<Chat />
</Panel>
</StyledSidePanel>
)
}
@@ -1,71 +0,0 @@
import type { ReceivedChatMessage } from '@livekit/components-core'
import * as React from 'react'
import { css } from '@/styled-system/css'
import { Text } from '@/primitives'
import { MessageFormatter } from '@livekit/components-react'
export interface ChatEntryProps extends React.HTMLAttributes<HTMLLIElement> {
entry: ReceivedChatMessage
hideMetadata?: boolean
messageFormatter?: MessageFormatter
}
export const ChatEntry: (
props: ChatEntryProps & React.RefAttributes<HTMLLIElement>
) => React.ReactNode = /* @__PURE__ */ React.forwardRef<
HTMLLIElement,
ChatEntryProps
>(function ChatEntry(
{ entry, hideMetadata = false, messageFormatter, ...props }: ChatEntryProps,
ref
) {
// Fixme - Livekit messageFormatter strips '\n' char
const formattedMessage = React.useMemo(() => {
return messageFormatter ? messageFormatter(entry.message) : entry.message
}, [entry.message, messageFormatter])
const time = new Date(entry.timestamp)
const locale = navigator ? navigator.language : 'en-US'
return (
<li
className={css({
display: 'flex',
flexDirection: 'column',
gap: '0.25rem',
})}
ref={ref}
title={time.toLocaleTimeString(locale, { timeStyle: 'full' })}
data-lk-message-origin={entry.from?.isLocal ? 'local' : 'remote'}
{...props}
>
{!hideMetadata && (
<span
className={css({
display: 'flex',
gap: '0.5rem',
paddingTop: '0.75rem',
})}
>
<Text bold={true} variant="sm">
{entry.from?.name ?? entry.from?.identity}
</Text>
<Text variant="sm" className={css({ color: 'gray.700' })}>
{time.toLocaleTimeString(locale, { timeStyle: 'short' })}
</Text>
</span>
)}
<Text
variant="sm"
margin={false}
className={css({
'& .lk-chat-link': {
color: 'blue',
textDecoration: 'underline',
},
})}
>
{formattedMessage}
</Text>
</li>
)
})
@@ -1,120 +0,0 @@
import { Button } from '@/primitives'
import { HStack } from '@/styled-system/jsx'
import { RiSendPlane2Fill } from '@remixicon/react'
import { useState, useEffect } from 'react'
import { TextArea } from '@/primitives/TextArea'
import { RefObject } from 'react'
import { useTranslation } from 'react-i18next'
const MAX_ROWS = 6
interface ChatInputProps {
inputRef: RefObject<HTMLTextAreaElement>
onSubmit: (text: string) => void
isSending: boolean
}
export const ChatInput = ({
inputRef,
onSubmit,
isSending,
}: ChatInputProps) => {
const { t } = useTranslation('rooms', { keyPrefix: 'controls.chat.input' })
const [text, setText] = useState('')
const [rows, setRows] = useState(1)
const handleSubmit = () => {
onSubmit(text)
setText('')
}
const isDisabled = !text.trim() || isSending
const submitOnEnter = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key !== 'Enter' || (e.key === 'Enter' && e.shiftKey)) return
e.preventDefault()
if (!isDisabled) handleSubmit()
}
useEffect(() => {
const resize = () => {
if (!inputRef.current) return
const textAreaLineHeight = 20 // Adjust this value based on your TextArea's line height
const previousRows = inputRef.current.rows
inputRef.current.rows = 1
const currentRows = Math.floor(
inputRef.current.scrollHeight / textAreaLineHeight
)
if (currentRows === previousRows) {
inputRef.current.rows = currentRows
}
if (currentRows >= MAX_ROWS) {
inputRef.current.rows = MAX_ROWS
inputRef.current.scrollTop = inputRef.current.scrollHeight
}
if (currentRows < MAX_ROWS) {
inputRef.current.style.overflowY = 'hidden'
} else {
inputRef.current.style.overflowY = 'auto'
}
setRows(currentRows < MAX_ROWS ? currentRows : MAX_ROWS)
}
resize()
}, [text, inputRef])
return (
<HStack
style={{
margin: '0.75rem 0 1.5rem',
padding: '0.5rem',
backgroundColor: '#f3f4f6',
borderRadius: 4,
}}
>
<TextArea
ref={inputRef}
onKeyDown={(e) => {
e.stopPropagation()
submitOnEnter(e)
}}
onKeyUp={(e) => e.stopPropagation()}
placeholder={t('textArea.placeholder')}
value={text}
onChange={(e) => {
setText(e.target.value)
}}
rows={rows || 1}
style={{
border: 'none',
resize: 'none',
height: 'auto',
minHeight: `34px`,
lineHeight: 1.25,
padding: '7px 10px',
overflowY: 'hidden',
}}
placeholderStyle={'strong'}
spellCheck={false}
maxLength={500}
aria-label={t('textArea.label')}
/>
<Button
square
invisible
size="sm"
onPress={handleSubmit}
isDisabled={isDisabled}
aria-label={t('button.label')}
>
<RiSendPlane2Fill />
</Button>
</HStack>
)
}
@@ -1,18 +1,19 @@
import { useTranslation } from 'react-i18next'
import { RiChat1Line } from '@remixicon/react'
import { useSnapshot } from 'valtio'
import { css } from '@/styled-system/css'
import { ToggleButton } from '@/primitives'
import { chatStore } from '@/stores/chat'
import { useSidePanel } from '../../hooks/useSidePanel'
import { css } from '@/styled-system/css'
import { useLayoutContext } from '@livekit/components-react'
import { useSnapshot } from 'valtio'
import { participantsStore } from '@/stores/participants'
export const ChatToggle = () => {
const { t } = useTranslation('rooms', { keyPrefix: 'controls.chat' })
const { t } = useTranslation('rooms')
const chatSnap = useSnapshot(chatStore)
const { dispatch, state } = useLayoutContext().widget
const tooltipLabel = state?.showChat ? 'open' : 'closed'
const { isChatOpen, toggleChat } = useSidePanel()
const tooltipLabel = isChatOpen ? 'open' : 'closed'
const participantsSnap = useSnapshot(participantsStore)
const showParticipants = participantsSnap.showParticipants
return (
<div
@@ -24,15 +25,17 @@ export const ChatToggle = () => {
<ToggleButton
square
legacyStyle
aria-label={t(tooltipLabel)}
tooltip={t(tooltipLabel)}
isSelected={isChatOpen}
onPress={() => toggleChat()}
data-attr={`controls-chat-${tooltipLabel}`}
aria-label={t(`controls.chat.${tooltipLabel}`)}
tooltip={t(`controls.chat.${tooltipLabel}`)}
isSelected={state?.showChat}
onPress={() => {
if (showParticipants) participantsStore.showParticipants = false
if (dispatch) dispatch({ msg: 'toggle_chat' })
}}
>
<RiChat1Line />
</ToggleButton>
{!!chatSnap.unreadMessages && (
{!!state?.unreadMessages && (
<div
className={css({
position: 'absolute',
@@ -1,38 +0,0 @@
import { useTranslation } from 'react-i18next'
import { RiHand } from '@remixicon/react'
import { ToggleButton } from '@/primitives'
import { css } from '@/styled-system/css'
import { useRoomContext } from '@livekit/components-react'
import { useRaisedHand } from '@/features/rooms/livekit/hooks/useRaisedHand'
export const HandToggle = () => {
const { t } = useTranslation('rooms', { keyPrefix: 'controls.hand' })
const room = useRoomContext()
const { isHandRaised, toggleRaisedHand } = useRaisedHand({
participant: room.localParticipant,
})
const tooltipLabel = isHandRaised ? 'lower' : 'raise'
return (
<div
className={css({
position: 'relative',
display: 'inline-block',
})}
>
<ToggleButton
square
legacyStyle
aria-label={t(tooltipLabel)}
tooltip={t(tooltipLabel)}
isSelected={isHandRaised}
onPress={() => toggleRaisedHand()}
data-attr={`controls-hand-${tooltipLabel}`}
>
<RiHand />
</ToggleButton>
</div>
)
}
@@ -1,37 +0,0 @@
import { useConnectionState, useRoomContext } from '@livekit/components-react'
import { Button } from '@/primitives'
import { navigateTo } from '@/navigation/navigateTo'
import { RiPhoneFill } from '@remixicon/react'
import { useTranslation } from 'react-i18next'
import { ConnectionState } from 'livekit-client'
export const LeaveButton = () => {
const { t } = useTranslation('rooms', { keyPrefix: 'controls' })
const room = useRoomContext()
const connectionState = useConnectionState(room)
return (
<Button
isDisabled={connectionState === ConnectionState.Disconnected}
variant={'danger'}
tooltip={t('leave')}
aria-label={t('leave')}
onPress={() => {
room
.disconnect(true)
.catch((e) =>
console.error('An error occurred while disconnecting:', e)
)
.finally(() => {
navigateTo('feedback')
})
}}
data-attr="controls-leave"
>
<RiPhoneFill
style={{
transform: 'rotate(135deg)',
}}
/>
</Button>
)
}
@@ -1,16 +1,13 @@
import { menuItemRecipe } from '@/primitives/menuItemRecipe'
import {
RiAccountBoxLine,
RiMegaphoneLine,
RiFeedbackLine,
RiQuestionLine,
RiSettings3Line,
} from '@remixicon/react'
import { MenuItem, Menu as RACMenu, Section } from 'react-aria-components'
import { MenuItem, Menu as RACMenu } 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 { RecordingMenuItem } from '@/features/rooms/livekit/components/controls/Options/RecordingMenuItem'
import { DialogState } from '@/features/rooms/livekit/components/controls/Options/OptionsButton'
// @todo try refactoring it to use MenuList component
export const OptionsMenuItems = ({
@@ -18,8 +15,8 @@ export const OptionsMenuItems = ({
}: {
onOpenDialog: Dispatch<SetStateAction<DialogState>>
}) => {
const { t } = useTranslation('rooms', { keyPrefix: 'options.items' })
const { toggleEffects } = useSidePanel()
const { t } = useTranslation('rooms')
return (
<RACMenu
style={{
@@ -27,34 +24,29 @@ export const OptionsMenuItems = ({
width: '300px',
}}
>
<Section>
<MenuItem
onAction={() => toggleEffects()}
className={menuItemRecipe({ icon: true })}
>
<RiAccountBoxLine size={20} />
{t('effects')}
</MenuItem>
<RecordingMenuItem />
</Section>
<Separator />
<Section>
<MenuItem
href="https://grist.incubateur.net/o/docs/forms/1YrfNP1QSSy8p2gCxMFnSf/4"
target="_blank"
className={menuItemRecipe({ icon: true })}
>
<RiMegaphoneLine size={20} />
{t('feedbacks')}
</MenuItem>
<MenuItem
className={menuItemRecipe({ icon: true })}
onAction={() => onOpenDialog('settings')}
>
<RiSettings3Line size={20} />
{t('settings')}
</MenuItem>
</Section>
<MenuItem
href="https://tchap.gouv.fr/#/room/!aGImQayAgBLjSBycpm:agent.dinum.tchap.gouv.fr?via=agent.dinum.tchap.gouv.fr"
target="_blank"
className={menuItemRecipe({ icon: true })}
>
<RiQuestionLine size={18} />
{t('options.items.support')}
</MenuItem>
<MenuItem
href="https://grist.incubateur.net/o/docs/forms/1YrfNP1QSSy8p2gCxMFnSf/4"
target="_blank"
className={menuItemRecipe({ icon: true })}
>
<RiFeedbackLine size={18} />
{t('options.items.feedbacks')}
</MenuItem>
<MenuItem
className={menuItemRecipe({ icon: true })}
onAction={() => onOpenDialog('settings')}
>
<RiSettings3Line size={18} />
{t('options.items.settings')}
</MenuItem>
</RACMenu>
)
}
@@ -1,52 +0,0 @@
import { MenuItem } from 'react-aria-components'
import { menuItemRecipe } from '@/primitives/menuItemRecipe.ts'
import { RiPauseCircleLine, RiRecordCircleLine } from '@remixicon/react'
import { useRecordRoom } from '@/features/rooms/livekit/api/recordRoom.ts'
import { useState } from 'react'
import { egressStore } from '@/stores/egress.ts'
import { useSnapshot } from 'valtio'
export const RecordingMenuItem = () => {
const { recordRoom, stopRecordingRoom } = useRecordRoom()
const egressSnap = useSnapshot(egressStore)
const egressId = egressSnap.egressId
const [isPending, setIsPending] = useState(false)
const handleAction = async () => {
if (egressId) {
setIsPending(true)
egressStore.egressIsStopping = true
const response = await stopRecordingRoom(egressId)
console.log(response)
egressStore.egressId = undefined
setIsPending(false)
} else {
setIsPending(true)
const response = await recordRoom()
egressStore.egressId = response['egress_id'] as string
setIsPending(false)
}
}
return (
<MenuItem
isDisabled={isPending}
className={menuItemRecipe({ icon: true })}
onAction={handleAction}
>
{egressId ? (
<>
<RiPauseCircleLine size={18} />
Arrêter l'enregistrement
</>
) : (
<>
<RiRecordCircleLine size={18} />
Enregistrer la réunion
</>
)}
</MenuItem>
)
}
@@ -1,82 +0,0 @@
import { css } from '@/styled-system/css'
import { HStack } from '@/styled-system/jsx'
import { Text } from '@/primitives/Text'
import { useTranslation } from 'react-i18next'
import { Avatar } from '@/components/Avatar'
import { getParticipantColor } from '@/features/rooms/utils/getParticipantColor'
import { Participant } from 'livekit-client'
import { isLocal } from '@/utils/livekit'
import { RiHand } from '@remixicon/react'
import { useLowerHandParticipant } from '@/features/rooms/livekit/api/lowerHandParticipant.ts'
import { Button } from '@/primitives'
type HandRaisedListItemProps = {
participant: Participant
}
export const HandRaisedListItem = ({
participant,
}: HandRaisedListItemProps) => {
const { t } = useTranslation('rooms')
const name = participant.name || participant.identity
const { lowerHandParticipant } = useLowerHandParticipant()
return (
<HStack
role="listitem"
justify="space-between"
key={participant.identity}
id={participant.identity}
className={css({
padding: '0.25rem 0',
width: 'full',
})}
>
<HStack>
<Avatar name={name} bgColor={getParticipantColor(participant)} />
<Text
variant={'sm'}
className={css({
userSelect: 'none',
cursor: 'default',
display: 'flex',
})}
>
<span
className={css({
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
maxWidth: '120px',
display: 'block',
})}
>
{name}
</span>
{isLocal(participant) && (
<span
className={css({
marginLeft: '.25rem',
whiteSpace: 'nowrap',
})}
>
({t('participants.you')})
</span>
)}
</Text>
</HStack>
<Button
square
invisible
size="sm"
onPress={() => lowerHandParticipant(participant)}
tooltip={t('participants.lowerParticipantHand', { name })}
data-attr="participants-lower-hand"
>
<RiHand />
</Button>
</HStack>
)
}
@@ -1,27 +0,0 @@
import { Button } from '@/primitives'
import { useLowerHandParticipants } from '@/features/rooms/livekit/api/lowerHandParticipants'
import { useTranslation } from 'react-i18next'
import { Participant } from 'livekit-client'
type LowerAllHandsButtonProps = {
participants: Array<Participant>
}
export const LowerAllHandsButton = ({
participants,
}: LowerAllHandsButtonProps) => {
const { lowerHandParticipants } = useLowerHandParticipants()
const { t } = useTranslation('rooms')
return (
<Button
aria-label={t('participants.lowerParticipantsHand')}
size="sm"
fullWidth
variant="text"
onPress={() => lowerHandParticipants(participants)}
data-attr="participants-lower-hands"
>
{t('participants.lowerParticipantsHand')}
</Button>
)
}
@@ -7,12 +7,15 @@ import { Avatar } from '@/components/Avatar'
import { getParticipantColor } from '@/features/rooms/utils/getParticipantColor'
import { Participant, Track } from 'livekit-client'
import { isLocal } from '@/utils/livekit'
import { Button as RACButton } from 'react-aria-components'
import { ActiveSpeaker } from '@/features/rooms/components/ActiveSpeaker'
import {
useIsSpeaking,
useTrackMutedIndicator,
} from '@livekit/components-react'
import Source = Track.Source
import { RiMicFill, RiMicOffFill } from '@remixicon/react'
import { RiMicOffLine } from '@remixicon/react'
import { TooltipWrapper } from '@/primitives/TooltipWrapper.tsx'
import { Button, Dialog, P } from '@/primitives'
import { useState } from 'react'
import { useMuteParticipant } from '@/features/rooms/livekit/api/muteParticipant'
@@ -33,10 +36,10 @@ const MuteAlertDialog = ({
<Dialog isOpen={isOpen} role="alertdialog">
<P>{t('participants.muteParticipantAlert.description', { name })}</P>
<HStack gap={1}>
<Button variant="text" size="sm" onPress={onClose}>
<Button size="sm" invisible onPress={onClose}>
{t('participants.muteParticipantAlert.cancel')}
</Button>
<Button variant="text" size="sm" onPress={onSubmit}>
<Button size="sm" invisible onPress={onSubmit}>
{t('participants.muteParticipantAlert.confirm')}
</Button>
</HStack>
@@ -56,40 +59,45 @@ const MicIndicator = ({ participant }: MicIndicatorProps) => {
source: Source.Microphone,
})
const isSpeaking = useIsSpeaking(participant)
const isDisabled = isLocal(participant) || (!isLocal(participant) && isMuted)
const [isAlertOpen, setIsAlertOpen] = useState(false)
const name = participant.name || participant.identity
return (
<>
<Button
square
invisible
size="sm"
tooltip={
isLocal(participant)
? t('participants.muteYourself')
: t('participants.muteParticipant', {
name,
})
}
isDisabled={isMuted}
onPress={() =>
!isMuted && isLocal(participant)
? muteParticipant(participant)
: setIsAlertOpen(true)
}
data-attr="participants-mute"
<TooltipWrapper
tooltip={t('participants.muteParticipant', {
name,
})}
tooltipType="instant"
>
{isMuted ? (
<RiMicOffFill color={'gray'} />
) : (
<RiMicFill
style={{
animation: isSpeaking ? 'pulse_mic 800ms infinite' : undefined,
}}
/>
)}
</Button>
<RACButton
isDisabled={isDisabled}
className={css({
padding: '10px',
minWidth: '24px',
minHeight: '24px',
borderRadius: '50%',
backgroundColor: 'transparent',
transition: 'background 200ms',
'&[data-hovered]': {
backgroundColor: '#f5f5f5',
},
'&[data-focused]': {
backgroundColor: '#f5f5f5',
},
})}
onPress={() => !isMuted && setIsAlertOpen(true)}
>
{isMuted ? (
<RiMicOffLine color="gray" />
) : (
<ActiveSpeaker isSpeaking={isSpeaking} />
)}
</RACButton>
</TooltipWrapper>
<MuteAlertDialog
isOpen={isAlertOpen}
onSubmit={() =>
@@ -111,6 +119,7 @@ export const ParticipantListItem = ({
}: ParticipantListItemProps) => {
const { t } = useTranslation('rooms')
const name = participant.name || participant.identity
return (
<HStack
role="listitem"
@@ -1,109 +0,0 @@
import { useState } from 'react'
import { css } from '@/styled-system/css'
import { ToggleButton } from 'react-aria-components'
import { HStack, styled, VStack } from '@/styled-system/jsx'
import { RiArrowUpSLine } from '@remixicon/react'
import { Participant } from 'livekit-client'
import { useTranslation } from 'react-i18next'
const ToggleHeader = styled(ToggleButton, {
base: {
minHeight: '40px', //fixme hardcoded value
paddingRight: '.5rem',
cursor: 'pointer',
display: 'flex',
justifyContent: 'space-between',
width: '100%',
alignItems: 'center',
transition: 'background 200ms',
borderTopRadius: '7px',
'&[data-hovered]': {
backgroundColor: '#f5f5f5',
},
},
})
const Container = styled('div', {
base: {
border: '1px solid #dadce0',
borderRadius: '8px',
margin: '0 .625rem',
},
})
const ListContainer = styled(VStack, {
base: {
borderTop: '1px solid #dadce0',
alignItems: 'start',
overflowY: 'scroll',
overflowX: 'hidden',
minHeight: 0,
flexGrow: 1,
display: 'flex',
paddingY: '0.5rem',
paddingX: '1rem',
gap: 0,
},
})
type ParticipantsCollapsableListProps = {
heading: string
participants: Array<Participant>
renderParticipant: (participant: Participant) => JSX.Element
action?: () => JSX.Element
}
export const ParticipantsCollapsableList = ({
heading,
participants,
renderParticipant,
action,
}: ParticipantsCollapsableListProps) => {
const { t } = useTranslation('rooms')
const [isOpen, setIsOpen] = useState(true)
const label = t(`participants.collapsable.${isOpen ? 'close' : 'open'}`, {
name: heading,
})
return (
<Container>
<ToggleHeader
isSelected={isOpen}
aria-label={label}
onPress={() => setIsOpen(!isOpen)}
style={{
borderRadius: !isOpen ? '7px' : undefined,
}}
>
<HStack
justify="space-between"
className={css({
margin: '0 1.25rem',
width: '100%',
})}
>
<div
className={css({
fontSize: '1rem',
})}
>
{heading}
</div>
<div>{participants?.length || 0}</div>
</HStack>
<RiArrowUpSLine
size={32}
style={{
transform: isOpen ? 'rotate(-180deg)' : undefined,
transition: 'transform 200ms',
}}
/>
</ToggleHeader>
{isOpen && (
<ListContainer>
{action && action()}
{participants.map((participant) => renderParticipant(participant))}
</ListContainer>
)}
</Container>
)
}
@@ -1,21 +1,26 @@
import { css } from '@/styled-system/css'
import { useParticipants } from '@livekit/components-react'
import { Div, H } from '@/primitives'
import { Heading } from 'react-aria-components'
import { Box, Button, Div } from '@/primitives'
import { text } from '@/primitives/Text'
import { RiCloseLine } from '@remixicon/react'
import { participantsStore } from '@/stores/participants'
import { useTranslation } from 'react-i18next'
import { ParticipantListItem } from '../../controls/Participants/ParticipantListItem'
import { ParticipantsCollapsableList } from '../../controls/Participants/ParticipantsCollapsableList'
import { HandRaisedListItem } from '../../controls/Participants/HandRaisedListItem'
import { LowerAllHandsButton } from '../../controls/Participants/LowerAllHandsButton'
import { allParticipantRoomEvents } from '@/features/rooms/livekit/constants/events'
import { ParticipantListItem } from '@/features/rooms/livekit/components/controls/Participants/ParticipantListItem'
import { VStack } from '@/styled-system/jsx'
// TODO: Optimize rendering performance, especially for longer participant lists, even though they are generally short.
export const ParticipantsList = () => {
const { t } = useTranslation('rooms', { keyPrefix: 'participants' })
const { t } = useTranslation('rooms')
// Preferred using the 'useParticipants' hook rather than the separate remote and local hooks,
// because the 'useLocalParticipant' hook does not update the participant's information when their
// metadata/name changes. The LiveKit team has marked this as a TODO item in the code.
const participants = useParticipants()
const participants = useParticipants({
updateOnlyOn: allParticipantRoomEvents,
})
const sortedRemoteParticipants = participants
.slice(1)
@@ -30,47 +35,59 @@ export const ParticipantsList = () => {
...sortedRemoteParticipants,
]
const raisedHandParticipants = participants.filter((participant) => {
const data = JSON.parse(participant.metadata || '{}')
return data.raised
})
// TODO - extract inline styling in a centralized styling file, and avoid magic numbers
return (
<Div overflowY="scroll">
<H
lvl={2}
className={css({
fontSize: '0.875rem',
fontWeight: 'bold',
color: '#5f6368',
padding: '0 1.5rem',
marginBottom: '0.83em',
})}
>
{t('subheading').toUpperCase()}
</H>
{raisedHandParticipants.length > 0 && (
<Div marginBottom=".9375rem">
<ParticipantsCollapsableList
heading={t('raisedHands')}
participants={raisedHandParticipants}
renderParticipant={(participant) => (
<HandRaisedListItem participant={participant} />
)}
action={() => (
<LowerAllHandsButton participants={raisedHandParticipants} />
)}
/>
</Div>
<Box
size="sm"
minWidth="300px"
className={css({
overflow: 'hidden',
display: 'flex',
flexDirection: 'column',
margin: '1.5rem 1.5rem 1.5rem 0',
})}
>
<Heading slot="title" level={3} className={text({ variant: 'h2' })}>
<span>{t('participants.heading')}</span>{' '}
<span
className={css({
marginLeft: '0.75rem',
fontWeight: 'normal',
fontSize: '1rem',
})}
>
{participants?.length}
</span>
</Heading>
<Div position="absolute" top="5" right="5">
<Button
invisible
size="xs"
onPress={() => (participantsStore.showParticipants = false)}
aria-label={t('participants.closeButton')}
tooltip={t('participants.closeButton')}
>
<RiCloseLine />
</Button>
</Div>
{sortedParticipants?.length > 0 && (
<VStack
role="list"
className={css({
alignItems: 'start',
gap: 'none',
overflowY: 'scroll',
overflowX: 'hidden',
minHeight: 0,
flexGrow: 1,
display: 'flex',
})}
>
{sortedParticipants.map((participant) => (
<ParticipantListItem participant={participant} />
))}
</VStack>
)}
<ParticipantsCollapsableList
heading={t('contributors')}
participants={sortedParticipants}
renderParticipant={(participant) => (
<ParticipantListItem participant={participant} />
)}
/>
</Div>
</Box>
)
}
@@ -2,11 +2,14 @@ import { useTranslation } from 'react-i18next'
import { RiGroupLine, RiInfinityLine } from '@remixicon/react'
import { ToggleButton } from '@/primitives'
import { css } from '@/styled-system/css'
import { useParticipants } from '@livekit/components-react'
import { useSidePanel } from '../../../hooks/useSidePanel'
import { useLayoutContext, useParticipants } from '@livekit/components-react'
import { useSnapshot } from 'valtio'
import { participantsStore } from '@/stores/participants'
export const ParticipantsToggle = () => {
const { t } = useTranslation('rooms', { keyPrefix: 'controls.participants' })
const { t } = useTranslation('rooms')
const { dispatch, state } = useLayoutContext().widget
/**
* Context could not be used due to inconsistent refresh behavior.
@@ -16,9 +19,10 @@ export const ParticipantsToggle = () => {
const participants = useParticipants()
const numParticipants = participants?.length
const { isParticipantsOpen, toggleParticipants } = useSidePanel()
const participantsSnap = useSnapshot(participantsStore)
const showParticipants = participantsSnap.showParticipants
const tooltipLabel = isParticipantsOpen ? 'open' : 'closed'
const tooltipLabel = showParticipants ? 'open' : 'closed'
return (
<div
@@ -30,11 +34,13 @@ export const ParticipantsToggle = () => {
<ToggleButton
square
legacyStyle
aria-label={t(tooltipLabel)}
tooltip={t(tooltipLabel)}
isSelected={isParticipantsOpen}
onPress={() => toggleParticipants()}
data-attr={`controls-participants-${tooltipLabel}`}
aria-label={t(`controls.participants.${tooltipLabel}`)}
tooltip={t(`controls.participants.${tooltipLabel}`)}
isSelected={showParticipants}
onPress={() => {
if (dispatch && state?.showChat) dispatch({ msg: 'toggle_chat' })
participantsStore.showParticipants = !showParticipants
}}
>
<RiGroupLine />
</ToggleButton>
@@ -1,56 +0,0 @@
import { Div, ToggleButton } from '@/primitives'
import { RiArrowUpLine, RiCloseFill, RiRectangleLine } from '@remixicon/react'
import { useTranslation } from 'react-i18next'
import { useTrackToggle, UseTrackToggleProps } from '@livekit/components-react'
import { Track } from 'livekit-client'
import React from 'react'
export const ScreenShareToggle = (
props: Omit<
UseTrackToggleProps<Track.Source.ScreenShare>,
'source' | 'captureOptions'
>
) => {
const { t } = useTranslation('rooms', { keyPrefix: 'controls.screenShare' })
const { buttonProps, enabled } = useTrackToggle({
...props,
source: Track.Source.ScreenShare,
captureOptions: { audio: true, selfBrowserSurface: 'include' },
})
const tooltipLabel = enabled ? 'stop' : 'start'
const Icon = enabled ? RiCloseFill : RiArrowUpLine
// fixme - remove ToggleButton custom styles when we design a proper icon
return (
<ToggleButton
isSelected={enabled}
square
legacyStyle
tooltip={t(tooltipLabel)}
onPress={(e) =>
buttonProps.onClick?.(
e as unknown as React.MouseEvent<HTMLButtonElement, MouseEvent>
)
}
style={{
maxWidth: '46px',
maxHeight: '46px',
}}
data-attr={`controls-screenshare-${tooltipLabel}`}
>
<Div position="relative">
<RiRectangleLine size={28} />
<Icon
size={16}
style={{
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
}}
/>
</Div>
</ToggleButton>
)
}
@@ -1,114 +0,0 @@
import { useTranslation } from 'react-i18next'
import {
useMediaDeviceSelect,
useTrackToggle,
UseTrackToggleProps,
} from '@livekit/components-react'
import { HStack } from '@/styled-system/jsx'
import { Button, Menu, MenuList } from '@/primitives'
import {
RemixiconComponentType,
RiArrowDownSLine,
RiMicLine,
RiMicOffLine,
RiVideoOffLine,
RiVideoOnLine,
} from '@remixicon/react'
import { Track } from 'livekit-client'
import { Shortcut } from '@/features/shortcuts/types'
import { ToggleDevice } from '@/features/rooms/livekit/components/controls/ToggleDevice.tsx'
export type ToggleSource = Exclude<
Track.Source,
Track.Source.ScreenShareAudio | Track.Source.Unknown
>
type SelectToggleSource = Exclude<ToggleSource, Track.Source.ScreenShare>
export type SelectToggleDeviceConfig = {
kind: MediaDeviceKind
iconOn: RemixiconComponentType
iconOff: RemixiconComponentType
shortcut?: Shortcut
longPress?: Shortcut
}
type SelectToggleDeviceConfigMap = {
[key in SelectToggleSource]: SelectToggleDeviceConfig
}
const selectToggleDeviceConfig: SelectToggleDeviceConfigMap = {
[Track.Source.Microphone]: {
kind: 'audioinput',
iconOn: RiMicLine,
iconOff: RiMicOffLine,
shortcut: {
key: 'd',
ctrlKey: true,
},
longPress: {
key: 'Space',
},
},
[Track.Source.Camera]: {
kind: 'videoinput',
iconOn: RiVideoOnLine,
iconOff: RiVideoOffLine,
shortcut: {
key: 'e',
ctrlKey: true,
},
},
}
type SelectToggleDeviceProps<T extends ToggleSource> =
UseTrackToggleProps<T> & {
onActiveDeviceChange: (deviceId: string) => void
source: SelectToggleSource
}
export const SelectToggleDevice = <T extends ToggleSource>({
onActiveDeviceChange,
...props
}: SelectToggleDeviceProps<T>) => {
const config = selectToggleDeviceConfig[props.source]
if (!config) {
throw new Error('Invalid source')
}
const { t } = useTranslation('rooms', { keyPrefix: 'join' })
const trackProps = useTrackToggle(props)
const { devices, activeDeviceId, setActiveMediaDevice } =
useMediaDeviceSelect({ kind: config.kind })
const selectLabel = t('choose', { keyPrefix: `join.${config.kind}` })
return (
<HStack gap={0}>
<ToggleDevice {...trackProps} config={config} />
<Menu>
<Button
tooltip={selectLabel}
aria-label={selectLabel}
groupPosition="right"
square
>
<RiArrowDownSLine />
</Button>
<MenuList
items={devices.map((d) => ({
value: d.deviceId,
label: d.label,
}))}
selectedItem={activeDeviceId}
onAction={(value) => {
setActiveMediaDevice(value as string)
onActiveDeviceChange(value as string)
}}
/>
</Menu>
</HStack>
)
}
@@ -1,71 +0,0 @@
import { ToggleButton } from '@/primitives'
import { useRegisterKeyboardShortcut } from '@/features/shortcuts/useRegisterKeyboardShortcut'
import { useMemo, useState } from 'react'
import { appendShortcutLabel } from '@/features/shortcuts/utils'
import { useTranslation } from 'react-i18next'
import { SelectToggleDeviceConfig } from './SelectToggleDevice'
import useLongPress from '@/features/shortcuts/useLongPress'
import { ActiveSpeaker } from '@/features/rooms/components/ActiveSpeaker'
import { useIsSpeaking, useLocalParticipant } from '@livekit/components-react'
export type ToggleDeviceProps = {
enabled: boolean
toggle: () => void
config: SelectToggleDeviceConfig
}
export const ToggleDevice = ({
config,
enabled,
toggle,
}: ToggleDeviceProps) => {
const { t } = useTranslation('rooms', { keyPrefix: 'join' })
const { kind, shortcut, iconOn, iconOff, longPress } = config
const [pushToTalk, setPushToTalk] = useState(false)
const onKeyDown = () => {
if (pushToTalk || enabled) return
toggle()
setPushToTalk(true)
}
const onKeyUp = () => {
if (!pushToTalk) return
toggle()
setPushToTalk(false)
}
useRegisterKeyboardShortcut({ shortcut, handler: toggle })
useLongPress({ keyCode: longPress?.key, onKeyDown, onKeyUp })
const toggleLabel = useMemo(() => {
const label = t(enabled ? 'disable' : 'enable', {
keyPrefix: `join.${kind}`,
})
return shortcut ? appendShortcutLabel(label, shortcut) : label
}, [enabled, kind, shortcut, t])
const Icon = enabled ? iconOn : iconOff
const { localParticipant } = useLocalParticipant()
const isSpeaking = useIsSpeaking(localParticipant)
if (kind === 'audioinput' && pushToTalk) {
return <ActiveSpeaker isSpeaking={isSpeaking} pushToTalk />
}
return (
<ToggleButton
isSelected={enabled}
variant={enabled ? undefined : 'danger'}
toggledStyles={false}
onPress={() => toggle()}
aria-label={toggleLabel}
tooltip={toggleLabel}
groupPosition="left"
>
<Icon />
</ToggleButton>
)
}
@@ -0,0 +1,33 @@
import { RoomEvent } from 'livekit-client'
// Issue: 'allRemoteParticipantRoomEvents' is not exposed or importable. One event is missing
// to trigger the real-time update of participants when they change their name.
// This code is duplicated from LiveKit.
export const allRemoteParticipantRoomEvents = [
RoomEvent.ConnectionStateChanged,
RoomEvent.RoomMetadataChanged,
RoomEvent.ActiveSpeakersChanged,
RoomEvent.ConnectionQualityChanged,
RoomEvent.ParticipantConnected,
RoomEvent.ParticipantDisconnected,
RoomEvent.ParticipantPermissionsChanged,
RoomEvent.ParticipantMetadataChanged,
RoomEvent.ParticipantNameChanged, // This element is missing in LiveKit and causes problems
RoomEvent.TrackMuted,
RoomEvent.TrackUnmuted,
RoomEvent.TrackPublished,
RoomEvent.TrackUnpublished,
RoomEvent.TrackStreamStateChanged,
RoomEvent.TrackSubscriptionFailed,
RoomEvent.TrackSubscriptionPermissionChanged,
RoomEvent.TrackSubscriptionStatusChanged,
]
export const allParticipantRoomEvents = [
...allRemoteParticipantRoomEvents,
RoomEvent.LocalTrackPublished,
RoomEvent.LocalTrackUnpublished,
]
@@ -1,23 +0,0 @@
import { LocalParticipant, Participant } from 'livekit-client'
import { useParticipantInfo } from '@livekit/components-react'
import { isLocal } from '@/utils/livekit'
type useRaisedHandProps = {
participant: Participant
}
export function useRaisedHand({ participant }: useRaisedHandProps) {
// fixme - refactor this part to rely on attributes
const { metadata } = useParticipantInfo({ participant })
const parsedMetadata = JSON.parse(metadata || '{}')
const toggleRaisedHand = () => {
if (isLocal(participant)) {
parsedMetadata.raised = !parsedMetadata.raised
const localParticipant = participant as LocalParticipant
localParticipant.setMetadata(JSON.stringify(parsedMetadata))
}
}
return { isHandRaised: parsedMetadata.raised, toggleRaisedHand }
}
@@ -1,41 +0,0 @@
import { useSnapshot } from 'valtio'
import { layoutStore } from '@/stores/layout'
export enum PanelId {
PARTICIPANTS = 'participants',
EFFECTS = 'effects',
CHAT = 'chat',
}
export const useSidePanel = () => {
const layoutSnap = useSnapshot(layoutStore)
const activePanelId = layoutSnap.activePanelId
const isParticipantsOpen = activePanelId == PanelId.PARTICIPANTS
const isEffectsOpen = activePanelId == PanelId.EFFECTS
const isChatOpen = activePanelId == PanelId.CHAT
const isSidePanelOpen = !!activePanelId
const toggleParticipants = () => {
layoutStore.activePanelId = isParticipantsOpen ? null : PanelId.PARTICIPANTS
}
const toggleChat = () => {
layoutStore.activePanelId = isChatOpen ? null : PanelId.CHAT
}
const toggleEffects = () => {
layoutStore.activePanelId = isEffectsOpen ? null : PanelId.EFFECTS
}
return {
activePanelId,
toggleParticipants,
toggleChat,
toggleEffects,
isChatOpen,
isParticipantsOpen,
isEffectsOpen,
isSidePanelOpen,
}
}
@@ -1,134 +0,0 @@
import type { ChatMessage, ChatOptions } from '@livekit/components-core'
import * as React from 'react'
import {
formatChatMessageLinks,
useChat,
useParticipants,
} from '@livekit/components-react'
import { useTranslation } from 'react-i18next'
import { useSnapshot } from 'valtio'
import { chatStore } from '@/stores/chat'
import { Div, Text } from '@/primitives'
import { ChatInput } from '../components/chat/Input'
import { ChatEntry } from '../components/chat/Entry'
import { useSidePanel } from '../hooks/useSidePanel'
export interface ChatProps
extends React.HTMLAttributes<HTMLDivElement>,
ChatOptions {}
/**
* The Chat component adds a basis chat functionality to the LiveKit room. The messages are distributed to all participants
* in the room. Only users who are in the room at the time of dispatch will receive the message.
*/
export function Chat({ ...props }: ChatProps) {
const { t } = useTranslation('rooms', { keyPrefix: 'chat' })
const inputRef = React.useRef<HTMLTextAreaElement>(null)
const ulRef = React.useRef<HTMLUListElement>(null)
const { send, chatMessages, isSending } = useChat()
const { isChatOpen } = useSidePanel()
const chatSnap = useSnapshot(chatStore)
// Use useParticipants hook to trigger a re-render when the participant list changes.
const participants = useParticipants()
const lastReadMsgAt = React.useRef<ChatMessage['timestamp']>(0)
async function handleSubmit(text: string) {
if (!send || !text) return
await send(text)
inputRef?.current?.focus()
}
React.useEffect(() => {
if (chatMessages.length > 0 && ulRef.current) {
ulRef.current?.scrollTo({ top: ulRef.current.scrollHeight })
}
}, [ulRef, chatMessages])
React.useEffect(() => {
if (chatMessages.length === 0) {
return
}
if (
isChatOpen &&
lastReadMsgAt.current !== chatMessages[chatMessages.length - 1]?.timestamp
) {
lastReadMsgAt.current = chatMessages[chatMessages.length - 1]?.timestamp
chatStore.unreadMessages = 0
return
}
const unreadMessageCount = chatMessages.filter(
(msg) => !lastReadMsgAt.current || msg.timestamp > lastReadMsgAt.current
).length
if (
unreadMessageCount > 0 &&
chatSnap.unreadMessages !== unreadMessageCount
) {
chatStore.unreadMessages = unreadMessageCount
}
}, [chatMessages, chatSnap.unreadMessages, isChatOpen])
const renderedMessages = React.useMemo(() => {
return chatMessages.map((msg, idx, allMsg) => {
const hideMetadata =
idx >= 1 &&
msg.timestamp - allMsg[idx - 1].timestamp < 60_000 &&
allMsg[idx - 1].from === msg.from
return (
<ChatEntry
key={msg.id ?? idx}
hideMetadata={hideMetadata}
entry={msg}
messageFormatter={formatChatMessageLinks}
/>
)
})
// This ensures that the chat message list is updated to reflect any changes in participant information.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [chatMessages, participants])
return (
<Div
display={'flex'}
padding={'0 1.5rem'}
flexGrow={1}
flexDirection={'column'}
minHeight={0}
{...props}
>
<Text
variant="sm"
style={{
padding: '0.75rem',
backgroundColor: '#f3f4f6',
borderRadius: 4,
marginBottom: '0.75rem',
}}
>
{t('disclaimer')}
</Text>
<Div
flexGrow={1}
flexDirection={'column'}
minHeight={0}
overflowY="scroll"
>
<ul className="lk-list lk-chat-messages" ref={ulRef}>
{renderedMessages}
</ul>
</Div>
<ChatInput
inputRef={inputRef}
onSubmit={(e) => handleSubmit(e)}
isSending={isSending}
/>
</Div>
)
}
@@ -4,20 +4,21 @@ import * as React from 'react'
import { supportsScreenSharing } from '@livekit/components-core'
import {
DisconnectButton,
LeaveIcon,
MediaDeviceMenu,
TrackToggle,
useMaybeLayoutContext,
usePersistentUserChoices,
} from '@livekit/components-react'
import { mergeProps } from '@/utils/mergeProps.ts'
import { StartMediaButton } from '../components/controls/StartMediaButton'
import { useMediaQuery } from '../hooks/useMediaQuery'
import { useTranslation } from 'react-i18next'
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'
import { ParticipantsToggle } from '@/features/rooms/livekit/components/controls/Participants/ParticipantsToggle'
import { ChatToggle } from '@/features/rooms/livekit/components/controls/ChatToggle'
/** @public */
export type ControlBarControls = {
@@ -36,7 +37,7 @@ export interface ControlBarProps extends React.HTMLAttributes<HTMLDivElement> {
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.
* This will enables the user to have the same device choices when they rejoin the room.
* @defaultValue true
* @alpha
*/
@@ -63,7 +64,9 @@ export function ControlBar({
variation,
saveUserChoices = true,
onDeviceError,
...props
}: ControlBarProps) {
const { t } = useTranslation('rooms')
const [isChatOpen, setIsChatOpen] = React.useState(false)
const layoutContext = useMaybeLayoutContext()
React.useEffect(() => {
@@ -79,8 +82,28 @@ export function ControlBar({
const defaultVariation = isTooLittleSpace ? 'minimal' : 'verbose'
variation ??= defaultVariation
const showIcon = React.useMemo(
() => variation === 'minimal' || variation === 'verbose',
[variation]
)
const showText = React.useMemo(
() => variation === 'textOnly' || variation === 'verbose',
[variation]
)
const browserSupportsScreenSharing = supportsScreenSharing()
const [isScreenShareEnabled, setIsScreenShareEnabled] = React.useState(false)
const onScreenShareChange = React.useCallback(
(enabled: boolean) => {
setIsScreenShareEnabled(enabled)
},
[setIsScreenShareEnabled]
)
const htmlProps = mergeProps({ className: 'lk-control-bar' }, props)
const {
saveAudioInputEnabled,
saveVideoInputEnabled,
@@ -101,55 +124,72 @@ export function ControlBar({
)
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 ?? '')
}
/>
<div {...htmlProps}>
<div className="lk-button-group">
<TrackToggle
source={Track.Source.Microphone}
showIcon={showIcon}
onChange={microphoneOnChange}
onDeviceError={(error) =>
onDeviceError?.({ source: Track.Source.Microphone, error })
}
>
{showText && t('controls.microphone')}
</TrackToggle>
<div className="lk-button-group-menu">
<MediaDeviceMenu
kind="audioinput"
onActiveDeviceChange={(_kind, deviceId) =>
saveAudioInputDeviceId(deviceId ?? '')
}
/>
</div>
</div>
<div className="lk-button-group">
<TrackToggle
source={Track.Source.Camera}
showIcon={showIcon}
onChange={cameraOnChange}
onDeviceError={(error) =>
onDeviceError?.({ source: Track.Source.Camera, error })
}
>
{showText && t('controls.camera')}
</TrackToggle>
<div className="lk-button-group-menu">
<MediaDeviceMenu
kind="videoinput"
onActiveDeviceChange={(_kind, deviceId) =>
saveVideoInputDeviceId(deviceId ?? '')
}
/>
</div>
</div>
{browserSupportsScreenSharing && (
<ScreenShareToggle
<TrackToggle
source={Track.Source.ScreenShare}
captureOptions={{ audio: true, selfBrowserSurface: 'include' }}
showIcon={showIcon}
onChange={onScreenShareChange}
onDeviceError={(error) =>
onDeviceError?.({ source: Track.Source.ScreenShare, error })
}
/>
>
{showText &&
t(
isScreenShareEnabled
? 'controls.stopScreenShare'
: 'controls.shareScreen'
)}
</TrackToggle>
)}
<HandToggle />
<ChatToggle />
<ParticipantsToggle />
<OptionsButton />
<LeaveButton />
<DisconnectButton>
{showIcon && <LeaveIcon />}
{showText && t('controls.leave')}
</DisconnectButton>
<StartMediaButton />
</div>
)
@@ -1,4 +1,9 @@
import type { TrackReferenceOrPlaceholder } from '@livekit/components-core'
import type {
MessageDecoder,
MessageEncoder,
TrackReferenceOrPlaceholder,
WidgetState,
} from '@livekit/components-core'
import {
isEqualTrackRef,
isTrackReference,
@@ -15,20 +20,21 @@ import {
GridLayout,
LayoutContextProvider,
RoomAudioRenderer,
MessageFormatter,
usePinnedTracks,
useTracks,
useCreateLayoutContext,
Chat,
} from '@livekit/components-react'
import { ControlBar } from './ControlBar'
import { styled } from '@/styled-system/jsx'
import { cva } from '@/styled-system/css'
import { MainNotificationToast } from '@/features/notifications/MainNotificationToast'
import { ParticipantsList } from '@/features/rooms/livekit/components/controls/Participants/ParticipantsList'
import { useSnapshot } from 'valtio'
import { participantsStore } from '@/stores/participants'
import { FocusLayout } from '../components/FocusLayout'
import { ParticipantTile } from '../components/ParticipantTile'
import { SidePanel } from '../components/SidePanel'
import { useSidePanel } from '../hooks/useSidePanel'
import { RecordingIndicator } from '@/features/rooms/components/RecordingIndicator.tsx'
const LayoutWrapper = styled(
'div',
@@ -37,7 +43,7 @@ const LayoutWrapper = styled(
position: 'relative',
display: 'flex',
width: '100%',
height: '100%',
height: 'calc(100% - var(--lk-control-bar-height))',
},
})
)
@@ -47,6 +53,9 @@ const LayoutWrapper = styled(
*/
export interface VideoConferenceProps
extends React.HTMLAttributes<HTMLDivElement> {
chatMessageFormatter?: MessageFormatter
chatMessageEncoder?: MessageEncoder
chatMessageDecoder?: MessageDecoder
/** @alpha */
SettingsComponent?: React.ComponentType
}
@@ -69,7 +78,17 @@ export interface VideoConferenceProps
* ```
* @public
*/
export function VideoConference({ ...props }: VideoConferenceProps) {
export function VideoConference({
chatMessageFormatter,
chatMessageDecoder,
chatMessageEncoder,
...props
}: VideoConferenceProps) {
const [widgetState, setWidgetState] = React.useState<WidgetState>({
showChat: false,
unreadMessages: 0,
showSettings: false,
})
const lastAutoFocusedScreenShareTrack =
React.useRef<TrackReferenceOrPlaceholder | null>(null)
@@ -81,6 +100,11 @@ export function VideoConference({ ...props }: VideoConferenceProps) {
{ updateOnlyOn: [RoomEvent.ActiveSpeakersChanged], onlySubscribed: false }
)
const widgetUpdate = (state: WidgetState) => {
log.debug('updating widget state', state)
setWidgetState(state)
}
const layoutContext = useCreateLayoutContext()
const screenShareTracks = tracks
@@ -147,7 +171,8 @@ export function VideoConference({ ...props }: VideoConferenceProps) {
])
/* eslint-enable react-hooks/exhaustive-deps */
const { isSidePanelOpen } = useSidePanel()
const participantsSnap = useSnapshot(participantsStore)
const showParticipants = participantsSnap.showParticipants
return (
<div className="lk-video-conference" {...props}>
@@ -155,55 +180,39 @@ export function VideoConference({ ...props }: VideoConferenceProps) {
<LayoutContextProvider
value={layoutContext}
// onPinChange={handleFocusStateChange}
onWidgetChange={widgetUpdate}
>
<div
// todo - extract these magic values into constant
style={{
position: 'absolute',
inset: isSidePanelOpen
? 'var(--lk-grid-gap) calc(358px + 3rem) calc(80px + var(--lk-grid-gap)) 16px'
: 'var(--lk-grid-gap) var(--lk-grid-gap) calc(80px + var(--lk-grid-gap))',
transition: 'inset .5s cubic-bezier(0.4,0,0.2,1) 5ms',
}}
>
<RecordingIndicator />
<div className="lk-video-conference-inner">
<LayoutWrapper>
<div
style={{ display: 'flex', position: 'relative', width: '100%' }}
>
{!focusTrack ? (
<div
className="lk-grid-layout-wrapper"
style={{ height: 'auto' }}
>
<GridLayout tracks={tracks} style={{ padding: 0 }}>
{!focusTrack ? (
<div
className="lk-grid-layout-wrapper"
style={{ height: 'auto' }}
>
<GridLayout tracks={tracks}>
<ParticipantTile />
</GridLayout>
</div>
) : (
<div className="lk-focus-layout-wrapper">
<FocusLayoutContainer>
<CarouselLayout tracks={carouselTracks}>
<ParticipantTile />
</GridLayout>
</div>
) : (
<div
className="lk-focus-layout-wrapper"
style={{ height: 'auto' }}
>
<FocusLayoutContainer style={{ padding: 0 }}>
<CarouselLayout
tracks={carouselTracks}
style={{
minWidth: '200px',
}}
>
<ParticipantTile />
</CarouselLayout>
{focusTrack && <FocusLayout trackRef={focusTrack} />}
</FocusLayoutContainer>
</div>
)}
</div>
</CarouselLayout>
{focusTrack && <FocusLayout trackRef={focusTrack} />}
</FocusLayoutContainer>
</div>
)}
<Chat
style={{ display: widgetState.showChat ? 'grid' : 'none' }}
messageFormatter={chatMessageFormatter}
messageEncoder={chatMessageEncoder}
messageDecoder={chatMessageDecoder}
/>
{showParticipants && <ParticipantsList />}
</LayoutWrapper>
<MainNotificationToast />
<ControlBar />
</div>
<ControlBar />
<SidePanel />
</LayoutContextProvider>
)}
<RoomAudioRenderer />
@@ -8,7 +8,6 @@ import { ErrorScreen } from '@/components/ErrorScreen'
import { useUser, UserAware } from '@/features/auth'
import { Conference } from '../components/Conference'
import { Join } from '../components/Join'
import { useKeyboardShortcuts } from '@/features/shortcuts/useKeyboardShortcuts'
export const Room = () => {
const { isLoggedIn } = useUser()
@@ -20,8 +19,6 @@ export const Room = () => {
const mode = isLoggedIn && history.state?.create ? 'create' : 'join'
const skipJoinScreen = isLoggedIn && mode === 'create'
useKeyboardShortcuts()
const clearRouterState = () => {
if (window?.history?.state) {
window.history.replaceState({}, '')
@@ -1,8 +1,7 @@
import { Trans, useTranslation } from 'react-i18next'
import { useLanguageLabels } from '@/i18n/useLanguageLabels'
import { A, Badge, Dialog, type DialogProps, Field, H, P } from '@/primitives'
import { logoutUrl, useUser } from '@/features/auth'
import { ProConnectButton } from '@/components/ProConnectButton'
import { authUrl, logoutUrl, useUser } from '@/features/auth'
export type SettingsDialogProps = Pick<DialogProps, 'isOpen' | 'onOpenChange'>
@@ -29,7 +28,9 @@ export const SettingsDialog = (props: SettingsDialogProps) => {
) : (
<>
<P>{t('account.youAreNotLoggedIn')}</P>
<ProConnectButton />
<P>
<A href={authUrl()}>{t('login', { ns: 'global' })}</A>
</P>
</>
)}
<H lvl={2}>{t('language.heading')}</H>
@@ -12,8 +12,6 @@ import {
import { AccountTab } from './tabs/AccountTab'
import { GeneralTab } from '@/features/settings/components/tabs/GeneralTab.tsx'
import { AudioTab } from '@/features/settings/components/tabs/AudioTab.tsx'
import { useSize } from '@/features/rooms/livekit/hooks/useResizeObserver'
import { useRef } from 'react'
const tabsStyle = css({
maxHeight: '40.625rem', // fixme size copied from meet settings modal
@@ -30,13 +28,13 @@ const tabListContainerStyle = css({
borderRight: '1px solid lightGray', // fixme poor color management
paddingY: '1rem',
paddingRight: '1.5rem',
flex: '0 0 16rem', // fixme size copied from meet settings modal
})
const tabPanelContainerStyle = css({
display: 'flex',
flexGrow: '1',
marginTop: '3.5rem',
minWidth: 0,
})
export type SettingsDialogExtended = Pick<
@@ -47,39 +45,25 @@ export type SettingsDialogExtended = Pick<
export const SettingsDialogExtended = (props: SettingsDialogExtended) => {
// display only icon on small screen
const { t } = useTranslation('settings')
const dialogEl = useRef<HTMLDivElement>(null)
const { width } = useSize(dialogEl)
const isWideScreen = !width || width >= 800 // fixme - hardcoded 50rem in pixel
return (
<Dialog innerRef={dialogEl} {...props} role="dialog" type="flex">
<Dialog {...props} role="dialog" type="flex">
<Tabs orientation="vertical" className={tabsStyle}>
<div
className={tabListContainerStyle}
style={{
flex: isWideScreen ? '0 0 16rem' : undefined,
paddingTop: !isWideScreen ? '64px' : undefined,
paddingRight: !isWideScreen ? '1rem' : undefined,
}}
>
{isWideScreen && (
<Heading slot="title" level={1} className={text({ variant: 'h1' })}>
{t('dialog.heading')}
</Heading>
)}
<div className={tabListContainerStyle}>
<Heading slot="title" level={1} className={text({ variant: 'h1' })}>
{t('dialog.heading')}
</Heading>
<TabList border={false} aria-label="Chat log orientation example">
<Tab icon highlight id="1">
<RiAccountCircleLine />
{isWideScreen && t('tabs.account')}
{t('tabs.account')}
</Tab>
<Tab icon highlight id="2">
<RiSpeakerLine />
{isWideScreen && t('tabs.audio')}
{t('tabs.audio')}
</Tab>
<Tab icon highlight id="3">
<RiSettings3Line />
{isWideScreen && t('tabs.general')}
{t('tabs.general')}
</Tab>
</TabList>
</div>
@@ -4,12 +4,11 @@ import {
usePersistentUserChoices,
useRoomContext,
} from '@livekit/components-react'
import { logoutUrl, useUser } from '@/features/auth'
import { authUrl, logoutUrl, useUser } from '@/features/auth'
import { css } from '@/styled-system/css'
import { TabPanel, TabPanelProps } from '@/primitives/Tabs'
import { HStack } from '@/styled-system/jsx'
import { useState } from 'react'
import { ProConnectButton } from '@/components/ProConnectButton'
export type AccountTabProps = Pick<DialogProps, 'onOpenChange'> &
Pick<TabPanelProps, 'id'>
@@ -59,7 +58,9 @@ export const AccountTab = ({ id, onOpenChange }: AccountTabProps) => {
) : (
<>
<P>{t('account.youAreNotLoggedIn')}</P>
<ProConnectButton />
<P>
<A href={authUrl()}>{t('login', { ns: 'global' })}</A>
</P>
</>
)}
<HStack
@@ -1,55 +1,10 @@
import { DialogProps, Field, H } from '@/primitives'
import { TabPanel, TabPanelProps } from '@/primitives/Tabs'
import {
useIsSpeaking,
useMediaDeviceSelect,
useRoomContext,
} from '@livekit/components-react'
import { useMediaDeviceSelect } from '@livekit/components-react'
import { isSafari } from '@/utils/livekit'
import { useTranslation } from 'react-i18next'
import { SoundTester } from '@/components/SoundTester'
import { HStack } from '@/styled-system/jsx'
import { ActiveSpeaker } from '@/features/rooms/components/ActiveSpeaker'
import { ReactNode } from 'react'
type RowWrapperProps = {
heading: string
children: ReactNode[]
}
const RowWrapper = ({ heading, children }: RowWrapperProps) => {
return (
<>
<H lvl={2}>{heading}</H>
<HStack
gap={0}
style={{
flexWrap: 'wrap',
}}
>
<div
style={{
flex: '1 1 215px',
minWidth: 0,
}}
>
{children[0]}
</div>
<div
style={{
width: '10rem',
justifyContent: 'center',
display: 'flex',
paddingLeft: '1.5rem',
}}
>
{children[1]}
</div>
</HStack>
</>
)
}
import { SoundTester } from '@/components/SoundTester.tsx'
export type AudioTabProps = Pick<DialogProps, 'onOpenChange'> &
Pick<TabPanelProps, 'id'>
@@ -58,9 +13,6 @@ type DeviceItems = Array<{ value: string; label: string }>
export const AudioTab = ({ id }: AudioTabProps) => {
const { t } = useTranslation('settings')
const { localParticipant } = useRoomContext()
const isSpeaking = useIsSpeaking(localParticipant)
const {
devices: devicesOut,
@@ -108,32 +60,20 @@ export const AudioTab = ({ id }: AudioTabProps) => {
return (
<TabPanel padding={'md'} flex id={id}>
<RowWrapper heading={t('audio.microphone.heading')}>
<Field
type="select"
label={t('audio.microphone.label')}
items={itemsIn}
defaultSelectedKey={
activeDeviceIdIn || getDefaultSelectedKey(itemsIn)
}
onSelectionChange={(key) => setActiveMediaDeviceIn(key as string)}
{...disabledProps}
style={{
width: '100%',
}}
/>
<>
{localParticipant.isMicrophoneEnabled ? (
<ActiveSpeaker isSpeaking={isSpeaking} />
) : (
<span>Micro désactivé</span>
)}
</>
</RowWrapper>
<H lvl={2}>{t('audio.microphone.heading')}</H>
<Field
type="select"
label={t('audio.microphone.label')}
items={itemsIn}
defaultSelectedKey={activeDeviceIdIn || getDefaultSelectedKey(itemsIn)}
onSelectionChange={(key) => setActiveMediaDeviceIn(key as string)}
{...disabledProps}
/>
{/* Safari has a known limitation where its implementation of 'enumerateDevices' does not include audio output devices.
To prevent errors or an empty selection list, we only render the speakers selection field on non-Safari browsers. */}
{!isSafari() && (
<RowWrapper heading={t('audio.speakers.heading')}>
<>
<H lvl={2}>{t('audio.speakers.heading')}</H>
<Field
type="select"
label={t('audio.speakers.label')}
@@ -145,12 +85,9 @@ export const AudioTab = ({ id }: AudioTabProps) => {
setActiveMediaDeviceOut(key as string)
}
{...disabledProps}
style={{
minWidth: 0,
}}
/>
<SoundTester />
</RowWrapper>
</>
)}
</TabPanel>
)
@@ -1,4 +0,0 @@
export type Shortcut = {
key: string
ctrlKey?: boolean
}
@@ -1,31 +0,0 @@
import { useEffect } from 'react'
import { useSnapshot } from 'valtio'
import { keyboardShortcutsStore } from '@/stores/keyboardShortcuts'
import { isMacintosh } from '@/utils/livekit'
import { formatShortcutKey } from './utils'
export const useKeyboardShortcuts = () => {
const shortcutsSnap = useSnapshot(keyboardShortcutsStore)
useEffect(() => {
// This approach handles basic shortcuts but isn't comprehensive.
// Issues might occur. First draft.
const onKeyDown = (e: KeyboardEvent) => {
const { key, metaKey, ctrlKey } = e
const shortcutKey = formatShortcutKey({
key,
ctrlKey: ctrlKey || (isMacintosh() && metaKey),
})
const shortcut = shortcutsSnap.shortcuts.get(shortcutKey)
if (!shortcut) return
e.preventDefault()
shortcut()
}
window.addEventListener('keydown', onKeyDown)
return () => {
window.removeEventListener('keydown', onKeyDown)
}
}, [shortcutsSnap])
}
@@ -1,47 +0,0 @@
import { useEffect, useRef } from 'react'
export type useLongPressProps = {
keyCode?: string
onKeyDown: () => void
onKeyUp: () => void
longPressThreshold?: number
}
export const useLongPress = ({
keyCode,
onKeyDown,
onKeyUp,
longPressThreshold = 300,
}: useLongPressProps) => {
const timeoutIdRef = useRef<ReturnType<typeof setTimeout> | null>(null)
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.code != keyCode || timeoutIdRef.current) return
timeoutIdRef.current = setTimeout(() => {
onKeyDown()
}, longPressThreshold)
}
const handleKeyUp = (event: KeyboardEvent) => {
if (event.code != keyCode || !timeoutIdRef.current) return
clearTimeout(timeoutIdRef.current)
timeoutIdRef.current = null
onKeyUp()
}
if (!keyCode) return
window.addEventListener('keydown', handleKeyDown)
window.addEventListener('keyup', handleKeyUp)
return () => {
window.removeEventListener('keydown', handleKeyDown)
window.removeEventListener('keyup', handleKeyUp)
}
}, [keyCode, onKeyDown, onKeyUp, longPressThreshold])
return
}
export default useLongPress
@@ -1,19 +0,0 @@
import { useEffect } from 'react'
import { keyboardShortcutsStore } from '@/stores/keyboardShortcuts'
import { formatShortcutKey } from '@/features/shortcuts/utils'
import { Shortcut } from '@/features/shortcuts/types'
export type useRegisterKeyboardShortcutProps = {
shortcut?: Shortcut
handler: () => void
}
export const useRegisterKeyboardShortcut = ({
shortcut,
handler,
}: useRegisterKeyboardShortcutProps) => {
useEffect(() => {
if (!shortcut) return
keyboardShortcutsStore.shortcuts.set(formatShortcutKey(shortcut), handler)
}, [handler, shortcut])
}
@@ -1,18 +0,0 @@
import { isMacintosh } from '@/utils/livekit'
import { Shortcut } from '@/features/shortcuts/types'
export const CTRL = 'ctrl'
export const formatShortcutKey = (shortcut: Shortcut) => {
if (shortcut.ctrlKey) return `${CTRL}+${shortcut.key.toUpperCase()}`
return shortcut.key.toUpperCase()
}
export const appendShortcutLabel = (label: string, shortcut: Shortcut) => {
if (!shortcut.key) return
let formattedKeyLabel = shortcut.key.toLowerCase()
if (shortcut.ctrlKey) {
formattedKeyLabel = `${isMacintosh() ? '⌘' : 'Ctrl'}+${formattedKeyLabel}`
}
return `${label} (${formattedKeyLabel})`
}
@@ -1,31 +0,0 @@
import { useEffect } from 'react'
import { Crisp } from 'crisp-sdk-web'
import { ApiUser } from '@/features/auth/api/ApiUser'
export const initializeSupportSession = (user: ApiUser) => {
if (!Crisp.isCrispInjected()) return
const { id, email } = user
Crisp.setTokenId(`meet-${id}`)
Crisp.user.setEmail(email)
}
export const terminateSupportSession = () => {
if (!Crisp.isCrispInjected()) return
Crisp.setTokenId()
Crisp.session.reset()
}
export type useSupportProps = {
id?: string
}
// Configure Crisp chat for real-time support across all pages.
export const useSupport = ({ id }: useSupportProps) => {
useEffect(() => {
if (!id || Crisp.isCrispInjected()) return
Crisp.configure(id)
Crisp.setHideOnMobile(true)
}, [id])
return null
}
+3 -8
View File
@@ -2,16 +2,13 @@ import { Link } from 'wouter'
import { css } from '@/styled-system/css'
import { Stack } from '@/styled-system/jsx'
import { useTranslation } from 'react-i18next'
import { Text, Button } from '@/primitives'
import { A, Text, Button } from '@/primitives'
import { SettingsButton } from '@/features/settings'
import { logoutUrl, useUser } from '@/features/auth'
import { authUrl, logoutUrl, useUser } from '@/features/auth'
import { useMatchesRoute } from '@/navigation/useMatchesRoute'
import { Feedback } from '@/components/Feedback'
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'
export const Header = () => {
const { t } = useTranslation()
@@ -66,7 +63,7 @@ export const Header = () => {
<nav>
<Stack gap={1} direction="row" align="center">
{isLoggedIn === false && !isHome && (
<ProConnectButton hint={false} />
<A href={authUrl()}>{t('login')}</A>
)}
{!!user && (
<Menu>
@@ -82,8 +79,6 @@ export const Header = () => {
items={[{ value: 'logout', label: t('logout') }]}
onAction={(value) => {
if (value === 'logout') {
terminateAnalyticsSession()
terminateSupportSession()
window.location.href = logoutUrl()
}
}}
+2 -6
View File
@@ -1,5 +1,5 @@
{
"app": "Visio",
"app": "Meet",
"backToHome": "",
"cancel": "",
"closeDialog": "",
@@ -12,11 +12,7 @@
},
"loading": "",
"loggedInUserTooltip": "",
"login": {
"buttonLabel": "",
"linkLabel": "",
"link": ""
},
"login": "Anmelden",
"logout": "",
"notFound": {
"heading": ""
+2 -2
View File
@@ -17,8 +17,8 @@
"laterMeetingDialog": {
"heading": "",
"description": "",
"copy": "",
"copied": "",
"permissions": ""
"copy": "",
"inputLabel": ""
}
}
@@ -1,10 +0,0 @@
{
"defaultName": "",
"joined": {
"description": ""
},
"raised": {
"description": "",
"cta": ""
}
}
+8 -79
View File
@@ -4,32 +4,15 @@
"heading": ""
},
"join": {
"videoinput": {
"choose": "",
"disable": "",
"enable": "",
"label": "",
"placeholder": ""
},
"audioinput": {
"choose": "",
"disable": "",
"enable": "",
"label": ""
},
"camlabel": "",
"heading": "",
"joinLabel": "",
"joinMeeting": "",
"toggleOff": "",
"toggleOn": "",
"userLabel": "",
"usernameHint": "",
"usernameLabel": ""
"micLabel": "",
"userLabel": ""
},
"leaveRoomPrompt": "",
"shareDialog": {
"copy": "",
"copied": "",
"heading": "",
"description": "",
"permissions": ""
@@ -47,24 +30,7 @@
"stopScreenShare": "",
"chat": {
"open": "",
"closed": "",
"input": {
"textArea": {
"label": "",
"placeholder": ""
},
"button": {
"label": ""
}
}
},
"hand": {
"raise": "",
"lower": ""
},
"screenShare": {
"start": "",
"stop": ""
"closed": ""
},
"leave": "",
"participants": {
@@ -78,55 +44,18 @@
"feedbacks": "",
"support": "",
"settings": "",
"username": "",
"effects": ""
"username": ""
}
},
"effects": {
"activateCamera": "",
"notAvailable": "",
"heading": "",
"blur": {
"light": "",
"normal": "",
"apply": "",
"clear": ""
},
"experimental": ""
},
"sidePanel": {
"heading": {
"participants": "",
"effects": "",
"chat": ""
},
"content": {
"participants": "",
"effects": "",
"chat": ""
},
"closeButton": ""
},
"chat": {
"disclaimer": ""
},
"participants": {
"subheading": "",
"contributors": "",
"collapsable": {
"open": "",
"close": ""
},
"heading": "",
"closeButton": "",
"you": "",
"muteYourself": "",
"muteParticipant": "",
"muteParticipantAlert": {
"description": "",
"confirm": "",
"cancel": ""
},
"raisedHands": "",
"lowerParticipantHand": "",
"lowerParticipantsHand": ""
}
}
}
+3 -7
View File
@@ -1,10 +1,10 @@
{
"app": "Visio",
"app": "Meet",
"backToHome": "Back to homescreen",
"cancel": "Cancel",
"closeDialog": "Close dialog",
"error": {
"heading": "An error occurred while loading the page"
"heading": "An error occured while loading the page"
},
"feedbackAlert": "Give us feedback",
"forbidden": {
@@ -12,11 +12,7 @@
},
"loading": "Loading…",
"loggedInUserTooltip": "Logged in as…",
"login": {
"buttonLabel": "Login with ProConnect",
"linkLabel": "What is ProConnect? - new tab",
"link": "What is ProConnect?"
},
"login": "Login",
"logout": "Logout",
"notFound": {
"heading": "Page not found"
+4 -4
View File
@@ -1,6 +1,6 @@
{
"createMeeting": "Create a meeting",
"heading": "Welcome in Visio",
"heading": "Welcome in Meet",
"intro": "Work easily, from anywhere.",
"joinInputError": "Use a meeting link or code. Examples:",
"joinInputExample": "URL or 10-letter code",
@@ -17,8 +17,8 @@
"laterMeetingDialog": {
"heading": "Your connection details",
"description": "Send this link to the people you want to invite to the meeting. They will be able to join without Agent Connect.",
"copy": "Copy the meeting link",
"copied": "Link copied to clipboard",
"permissions": "People with this link do not need your permission to join this meeting."
"copied": "Copied",
"copy": "Copy",
"inputLabel": "Meeting link"
}
}
@@ -1,10 +0,0 @@
{
"defaultName": "A contributor",
"joined": {
"description": "{{name}} has joined the room"
},
"raised": {
"description": "{{name}} has raised their hand.",
"cta": "Open waiting list"
}
}
+14 -83
View File
@@ -1,35 +1,18 @@
{
"feedback": {
"body": "Please fill out the form available in the header to give us your precious feedback! Thanks.",
"heading": "Help us improve Visio"
"heading": "Help us improve Meet"
},
"join": {
"videoinput": {
"choose": "Select camera",
"disable": "Disable camera",
"enable": "Enable camera",
"label": "Camera",
"placeholder": "Enable camera to see the preview"
},
"audioinput": {
"choose": "Select microphone",
"disable": "Disable microphone",
"enable": "Enable microphone",
"label": "Microphone"
},
"heading": "Verify your settings",
"camlabel": "Camera",
"heading": "Join the meeting",
"joinLabel": "Join",
"joinMeeting": "Join meeting",
"toggleOff": "Click to turn off",
"toggleOn": "Click to turn on",
"userLabel": "",
"usernameHint": "Shown to other participants",
"usernameLabel": "Your name"
"micLabel": "Microphone",
"userLabel": "Your name"
},
"leaveRoomPrompt": "This will make you leave the meeting.",
"shareDialog": {
"copy": "Copy the meeting link",
"copied": "Link copied to clipboard",
"copy": "Copy link",
"heading": "Your meeting is ready",
"description": "Share this link with people you want to invite to the meeting.",
"permissions": "People with this link do not need your permission to join this meeting."
@@ -43,26 +26,11 @@
"controls": {
"microphone": "Microphone",
"camera": "Camera",
"shareScreen": "Share screen",
"stopScreenShare": "Stop screen share",
"chat": {
"open": "Close the chat",
"closed": "Open the chat",
"input": {
"textArea": {
"label": "Enter a message",
"placeholder": "Enter a message"
},
"button": {
"label": "Send message"
}
}
},
"hand": {
"raise": "Raise hand",
"lower": "Lower hand"
},
"screenShare": {
"start": "Share screen",
"stop": "Stop screen share"
"closed": "Open the chat"
},
"leave": "Leave",
"participants": {
@@ -76,55 +44,18 @@
"feedbacks": "Give us feedbacks",
"support": "Get Help on Tchap",
"settings": "Settings",
"username": "Update Your Name",
"effects": "Apply effects"
"username": "Update Your Name"
}
},
"effects": {
"activateCamera": "Your camera is disabled. Choose an option to enable it.",
"notAvailable": "The blur effect will be available soon on your browser. We're working on it! In the meantime, you can use Google Chrome :(",
"heading": "Blur",
"blur": {
"light": "Light blur",
"normal": "Blur",
"apply": "Enable blur",
"clear": "Disable blur"
},
"experimental": "Experimental feature. A v2 is coming for full browser support and improved quality."
},
"sidePanel": {
"heading": {
"participants": "Participants",
"effects": "Effects",
"chat": "Messages in the chat"
},
"content": {
"participants": "participants",
"effects": "effects",
"chat": "messages"
},
"closeButton": "Hide {{content}}"
},
"chat": {
"disclaimer": "The messages are visible to participants only at the time they are sent. All messages are deleted at the end of the call."
},
"participants": {
"subheading": "In room",
"heading": "Participants",
"closeButton": "Hide participants",
"you": "You",
"contributors": "Contributors",
"collapsable": {
"open": "Open {{name}} list",
"close": "Close {{name}} list"
},
"muteYourself": "Close your mic",
"muteParticipant": "Close the mic of {{name}}",
"muteParticipantAlert": {
"description": "Mute {{name}} for all participants? {{name}} will be the only one who can unmute themselves.",
"description": "Mute {{name}} for all participants? {{name}} is the only person who can unmute themselves.",
"confirm": "Mute",
"cancel": "Cancel"
},
"raisedHands": "Raised hands",
"lowerParticipantHand": "Lower {{name}}'s hand",
"lowerParticipantsHand": "Lower all hands"
}
}
}
+1 -1
View File
@@ -3,7 +3,7 @@
"currentlyLoggedAs": "You are currently logged in as <0>{{user}}</0>",
"heading": "Account",
"youAreNotLoggedIn": "You are not logged in.",
"nameLabel": "Your Name",
"nameLabel": "Votre Nom",
"authentication": "Authentication"
},
"audio": {
+2 -6
View File
@@ -1,5 +1,5 @@
{
"app": "Visio",
"app": "Meet",
"backToHome": "Retour à l'accueil",
"cancel": "Annuler",
"closeDialog": "Fermer la fenêtre modale",
@@ -12,11 +12,7 @@
},
"loading": "Chargement…",
"loggedInUserTooltip": "Connecté en tant que…",
"login": {
"buttonLabel": "S'identifier avec ProConnect",
"linkLabel": "Qu'est-ce que ProConnect ? - nouvelle fenêtre",
"link": "Qu'est-ce que ProConnect ?"
},
"login": "Se connecter",
"logout": "Se déconnecter",
"notFound": {
"heading": "Page introuvable"
+4 -4
View File
@@ -1,6 +1,6 @@
{
"createMeeting": "Créer une réunion",
"heading": "Visio",
"heading": "Meet",
"intro": "Collaborez en toute simplicité, où que vous soyez.",
"joinInputError": "Saisissez un lien ou un code de réunion. Exemples :",
"joinInputExample": "Un code de réunion ressemble à ceci : abc-defg-hij",
@@ -17,8 +17,8 @@
"laterMeetingDialog": {
"heading": "Vos informations de connexion",
"description": "Envoyez ce lien aux personnes que vous souhaitez inviter à la réunion. Ils pourront la rejoindre sans Agent Connect.",
"copy": "Copier le lien de la réunion",
"copied": "Lien copié dans le presse-papiers",
"permissions": "Les personnes disposant de ce lien n'ont pas besoin de votre autorisation pour rejoindre cette réunion."
"copied": "Lien copié",
"copy": "Copier le lien",
"inputLabel": "Lien vers la réunion"
}
}
@@ -1,10 +0,0 @@
{
"defaultName": "Un contributeur",
"joined": {
"description": "{{name}} participe à l'appel"
},
"raised": {
"description": "{{name}} a levé la main.",
"cta": "Ouvrir la file d'attente"
}
}
+13 -82
View File
@@ -1,35 +1,18 @@
{
"feedback": {
"body": "Remplissez le formulaire disponible dans l'entête du site pour nous donner votre avis sur l'outil. Vos retours sont précieux ! Merci.",
"heading": "Aidez-nous à améliorer Visio"
"heading": "Aidez-nous à améliorer Meet"
},
"join": {
"videoinput": {
"choose": "Choisir la webcam",
"disable": "Désactiver la webcam",
"enable": "Activer la webcam",
"label": "Webcam",
"placeholder": "Activez la webcam pour prévisualiser l'affichage"
},
"audioinput": {
"choose": "Choisir le micro",
"disable": "Désactiver le micro",
"enable": "Activer le micro",
"label": "Microphone"
},
"heading": "Vérifiez vos paramètres",
"camlabel": "Webcam",
"heading": "Rejoindre la réunion",
"joinLabel": "Rejoindre",
"joinMeeting": "Rejoindre la réjoindre",
"toggleOff": "Cliquez pour désactiver",
"toggleOn": "Cliquez pour activer",
"userLabel": "",
"usernameHint": "Affiché aux autres participants",
"usernameLabel": "Votre nom"
"micLabel": "Micro",
"userLabel": "Votre nom"
},
"leaveRoomPrompt": "Revenir à l'accueil vous fera quitter la réunion.",
"shareDialog": {
"copy": "Copier le lien de la réunion",
"copied": "Lien copié dans le presse-papiers",
"copy": "Copier le lien",
"heading": "Votre réunion est prête",
"description": "Partagez ce lien avec les personnes que vous souhaitez inviter à la réunion.",
"permissions": "Les personnes disposant de ce lien n'ont pas besoin de votre autorisation pour rejoindre cette réunion."
@@ -43,26 +26,11 @@
"controls": {
"microphone": "Microphone",
"camera": "Camera",
"shareScreen": "Partager l'écran",
"stopScreenShare": "Arrêter le partage",
"chat": {
"open": "Masquer le chat",
"closed": "Afficher le chat",
"input": {
"textArea": {
"label": "Ecrire un message",
"placeholder": "Ecrire un message"
},
"button": {
"label": "Envoyer un message"
}
}
},
"hand": {
"raise": "Lever la main",
"lower": "Baisser la main"
},
"screenShare": {
"start": "Partager l'écran",
"stop": "Arrêter le partage"
"closed": "Afficher le chat"
},
"leave": "Quitter",
"participants": {
@@ -76,55 +44,18 @@
"feedbacks": "Partager votre avis",
"support": "Obtenir de l'aide sur Tchap",
"settings": "Paramètres",
"username": "Choisir votre nom",
"effects": "Appliquer des effets"
"username": "Choisir votre nom"
}
},
"effects": {
"activateCamera": "Votre camera est désactivée. Choisissez une option pour l'activer.",
"notAvailable": "L'effet de flou sera bientôt disponible sur votre navigateur. Nous y travaillons ! En attendant, vous pouvez utiliser Google Chrome :(",
"heading": "Flou",
"blur": {
"light": "Léger flou",
"normal": "Flou",
"apply": "Appliquer le flou",
"clear": "Désactiver le flou"
},
"experimental": "Fonctionnalité expérimentale. Une v2 arrive pour un support complet sur tous les navigateurs et une meilleur qualité."
},
"sidePanel": {
"heading": {
"participants": "Participants",
"effects": "Effets",
"chat": "Messages dans l'appel"
},
"content": {
"participants": "les participants",
"effects": "les effets",
"chat": "les messages"
},
"closeButton": "Masquer {{content}}"
},
"chat": {
"disclaimer": "Les messages sont visibles par les participants uniquement au moment de\nleur envoi. Tous les messages sont supprimés à la fin de l'appel."
},
"participants": {
"subheading": "Dans la réunion",
"heading": "Participants",
"closeButton": "Masquer les participants",
"you": "Vous",
"contributors": "Contributeurs",
"collapsable": {
"open": "Ouvrir la liste {{name}}",
"close": "Fermer la liste {{name}}"
},
"muteYourself": "Couper votre micro",
"muteParticipant": "Couper le micro de {{name}}",
"muteParticipantAlert": {
"description": "Couper le micro de {{name}} pour tous les participants ? {{name}} est la seule personne habilitée à réactiver son micro",
"confirm": "Couper le micro",
"cancel": "Annuler"
},
"raisedHands": "Mains levées",
"lowerParticipantHand": "Baisser la main de {{name}}",
"lowerParticipantsHand": "Baisser la main de tous les participants"
}
}
}

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