Compare commits

..

5 Commits

Author SHA1 Message Date
lebaudantoine dc9dedb91b wip update tag 2024-09-12 11:19:23 +02:00
lebaudantoine 3da3641a19 🚧 (frontend) Add start/stop audio recording menu item
Functional but not mergeable—remote participants need to be informed
when recording starts, including the egress ID to prevent collisions.

Quick and dirty implementation.
2024-09-12 10:58:21 +02:00
lebaudantoine 21626b4c4c 🚧(frontend) show recording status indicator
Room recording status triggers only when the egress worker is active.
Querying the Egress API starts the worker, which joins the room as an invisible participant.
The room is marked as "recording" only once the worker has fully joined.
Not mergeable yet.

Sadly the room event gives no information about the Egress worker’s ID.
2024-09-12 10:58:19 +02:00
lebaudantoine f16cedf5a5 🚧(frontend) draft audio-only room recording method
(roomId is wrongly named, and designates room’s slug)

Room’s slug are passed in filenames to simplify post-processing. Thus, from filename,
we can retrieve the room being recorded.

Current limitation: audio tracks are mixed, requiring diarization.
Future plans include full audio + video recording for a more complete meeting experience.
2024-09-12 10:49:00 +02:00
lebaudantoine 00e4679dc5 🚧(backend) allow room recording
Added 'room_record' grant to LiveKit token to enable room recording.
Initial implementation allows any authenticated user to record,
but this needs refinement.

Only admins and persisted room members should have this capability,
as they are the ones notified post-recording. Not ready for merge.
2024-09-12 10:49:00 +02:00
114 changed files with 3662 additions and 5632 deletions
+2 -15
View File
@@ -1,5 +1,4 @@
name: Docker Hub Workflow name: Docker Hub Workflow
run-name: Docker Hub Workflow
on: on:
workflow_dispatch: workflow_dispatch:
@@ -49,15 +48,9 @@ jobs:
name: Login to DockerHub name: Login to DockerHub
if: github.event_name != 'pull_request' if: github.event_name != 'pull_request'
run: echo "$DOCKER_HUB_PASSWORD" | docker login -u "$DOCKER_HUB_USER" --password-stdin 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 name: Build and push
uses: docker/build-push-action@v6 uses: docker/build-push-action@v5
with: with:
context: . context: .
target: backend-production target: backend-production
@@ -99,15 +92,9 @@ jobs:
name: Login to DockerHub name: Login to DockerHub
if: github.event_name != 'pull_request' if: github.event_name != 'pull_request'
run: echo "$DOCKER_HUB_PASSWORD" | docker login -u "$DOCKER_HUB_USER" --password-stdin 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 name: Build and push
uses: docker/build-push-action@v6 uses: docker/build-push-action@v5
with: with:
context: . context: .
file: ./src/frontend/Dockerfile 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 - age1tl80n23wq6zxegupwn70ew0yp225ua5v4dk800x7g2w6pvlxz46qk592pa #marie
- age1qy04neuzwpasmvljqrcvhwnf0kz5cpyteze38c8avp0czewskasszv9pyw #argocd - age1qy04neuzwpasmvljqrcvhwnf0kz5cpyteze38c8avp0czewskasszv9pyw #argocd
- age18fgn6j2vwwswqcpv9xpcehq8mrf9zs2sglwkamp3tzwx8d9jq9jsrskrk9 #manuuu - age18fgn6j2vwwswqcpv9xpcehq8mrf9zs2sglwkamp3tzwx8d9jq9jsrskrk9 #manuuu
- age1hm2hsfgjezpsc3k0y5w5feq9t8vl3seq04qjhgt6ztd6403wfvpsgxu09m # github-repo
- age1hnhuzj96ktkhpyygvmz0x9h8mfvssz7ss6emmukags644mdhf4msajk93r # Samuel Paccoud
+25 -16
View File
@@ -1,14 +1,15 @@
# Django Meet # Django Meet
# ---- base image to inherit from ---- # ---- 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 # 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 # Upgrade system packages to install security updates
RUN apk update && \ RUN apt-get update && \
apk upgrade apt-get -y upgrade && \
rm -rf /var/lib/apt/lists/*
# ---- Back-end builder image ---- # ---- Back-end builder image ----
FROM base as back-builder FROM base as back-builder
@@ -37,9 +38,12 @@ RUN yarn install --frozen-lockfile && \
FROM base as link-collector FROM base as link-collector
ARG MEET_STATIC_ROOT=/data/static ARG MEET_STATIC_ROOT=/data/static
RUN apk add \ # Install libpangocairo & rdfind
pango \ RUN apt-get update && \
rdfind apt-get install -y \
libpangocairo-1.0-0 \
rdfind && \
rm -rf /var/lib/apt/lists/*
# Copy installed python dependencies # Copy installed python dependencies
COPY --from=back-builder /install /usr/local COPY --from=back-builder /install /usr/local
@@ -62,14 +66,17 @@ FROM base as core
ENV PYTHONUNBUFFERED=1 ENV PYTHONUNBUFFERED=1
RUN apk add \ # Install required system libs
gettext \ RUN apt-get update && \
cairo \ apt-get install -y \
libffi-dev \ gettext \
gdk-pixbuf \ libcairo2 \
pango \ libffi-dev \
shared-mime-info libgdk-pixbuf2.0-0 \
libpango-1.0-0 \
libpangocairo-1.0-0 \
shared-mime-info && \
rm -rf /var/lib/apt/lists/*
# Copy entrypoint # Copy entrypoint
COPY ./docker/files/usr/local/bin/entrypoint /usr/local/bin/entrypoint COPY ./docker/files/usr/local/bin/entrypoint /usr/local/bin/entrypoint
@@ -99,7 +106,9 @@ FROM core as backend-development
USER root:root USER root:root
# Install psql # 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 # Uninstall Meet and re-install it in editable mode along with development
# dependencies # 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, "enabled": false,
"groupName": "ignored js dependencies", "groupName": "ignored js dependencies",
"matchManagers": ["npm"], "matchManagers": ["npm"],
"matchPackageNames": [ "matchPackageNames": []
"eslint"
]
} }
] ]
} }
+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("")
-23
View File
@@ -15,8 +15,6 @@ from django.utils.functional import lazy
from django.utils.text import capfirst, slugify from django.utils.text import capfirst, slugify
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from django.template.loader import render_to_string
from timezone_field import TimeZoneField from timezone_field import TimeZoneField
logger = getLogger(__name__) logger = getLogger(__name__)
@@ -327,24 +325,3 @@ class Room(Resource):
else: else:
raise ValidationError({"name": f'Room name "{self.name:s}" is reserved.'}) raise ValidationError({"name": f'Room name "{self.name:s}" is reserved.'})
super().clean_fields(exclude=exclude) 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 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 from core.authentication.urls import urlpatterns as oidc_urls
# - Main endpoints # - Main endpoints
@@ -23,8 +23,6 @@ urlpatterns = [
[ [
*router.urls, *router.urls,
*oidc_urls, *oidc_urls,
path("config/", get_frontend_configuration, name="config"),
path("minio-webhook/", demo.minio_webhook, name="demo"),
] ]
), ),
), ),
-83
View File
@@ -251,19 +251,6 @@ class Base(Configuration):
"REDOC_DIST": "SIDECAR", "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 # Mail
EMAIL_BACKEND = values.Value("django.core.mail.backends.smtp.EmailBackend") EMAIL_BACKEND = values.Value("django.core.mail.backends.smtp.EmailBackend")
EMAIL_HOST = values.Value(None) EMAIL_HOST = values.Value(None)
@@ -271,7 +258,6 @@ class Base(Configuration):
EMAIL_HOST_PASSWORD = values.Value(None) EMAIL_HOST_PASSWORD = values.Value(None)
EMAIL_PORT = values.PositiveIntegerValue(None) EMAIL_PORT = values.PositiveIntegerValue(None)
EMAIL_USE_TLS = values.BooleanValue(False) EMAIL_USE_TLS = values.BooleanValue(False)
EMAIL_USE_SSL = values.BooleanValue(False)
EMAIL_FROM = values.Value("from@example.com") EMAIL_FROM = values.Value("from@example.com")
AUTH_USER_MODEL = "core.User" AUTH_USER_MODEL = "core.User"
@@ -386,53 +372,6 @@ class Base(Configuration):
None, environ_name="ANALYTICS_KEY", environ_prefix=None 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 # pylint: disable=invalid-name
@property @property
def ENVIRONMENT(self): def ENVIRONMENT(self):
@@ -576,20 +515,6 @@ class Production(Base):
ALLOWED_HOSTS=["foo.com", "foo.fr"] ALLOWED_HOSTS=["foo.com", "foo.fr"]
""" """
LOGGING = {
"version": 1,
"disable_existing_loggers": False,
"handlers": {
"console": {
"class": "logging.StreamHandler",
},
},
"root": {
"handlers": ["console"],
"level": "INFO",
},
}
# Security # Security
ALLOWED_HOSTS = [ ALLOWED_HOSTS = [
*values.ListValue([], environ_name="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. # In other cases, you should comment the following line to avoid security issues.
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https") 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` # Modern browsers require to have the `secure` attribute on cookies with `Samesite=none`
CSRF_COOKIE_SECURE = True CSRF_COOKIE_SECURE = True
+10 -12
View File
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "meet" name = "meet"
version = "0.1.7" version = "0.1.5"
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }] authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
classifiers = [ classifiers = [
"Development Status :: 5 - Production/Stable", "Development Status :: 5 - Production/Stable",
@@ -25,7 +25,7 @@ license = { file = "LICENSE" }
readme = "README.md" readme = "README.md"
requires-python = ">=3.10" requires-python = ">=3.10"
dependencies = [ dependencies = [
"boto3==1.35.19", "boto3==1.35.10",
"Brotli==1.1.0", "Brotli==1.1.0",
"celery[redis]==5.4.0", "celery[redis]==5.4.0",
"django-configurations==2.5.1", "django-configurations==2.5.1",
@@ -36,11 +36,11 @@ dependencies = [
"django-redis==5.4.0", "django-redis==5.4.0",
"django-storages[s3]==1.14.4", "django-storages[s3]==1.14.4",
"django-timezone-field>=5.1", "django-timezone-field>=5.1",
"django==5.1.1", "django==5.1",
"djangorestframework==3.15.2", "djangorestframework==3.15.2",
"drf_spectacular==0.27.2", "drf_spectacular==0.27.2",
"dockerflow==2024.4.2", "dockerflow==2024.4.2",
"easy_thumbnails==2.10", "easy_thumbnails==2.9",
"factory_boy==3.3.1", "factory_boy==3.3.1",
"freezegun==1.5.1", "freezegun==1.5.1",
"gunicorn==23.0.0", "gunicorn==23.0.0",
@@ -48,18 +48,16 @@ dependencies = [
"june-analytics-python==2.3.0", "june-analytics-python==2.3.0",
"markdown==3.7", "markdown==3.7",
"nested-multipart-parser==1.5.0", "nested-multipart-parser==1.5.0",
"psycopg[binary]==3.2.2", "psycopg[binary]==3.2.1",
"PyJWT==2.9.0", "PyJWT==2.9.0",
"python-frontmatter==1.1.0", "python-frontmatter==1.1.0",
"requests==2.32.3", "requests==2.32.3",
"sentry-sdk==2.14.0", "sentry-sdk==2.13.0",
"url-normalize==1.4.3", "url-normalize==1.4.3",
"WeasyPrint>=60.2", "WeasyPrint>=60.2",
"whitenoise==6.7.0", "whitenoise==6.7.0",
"mozilla-django-oidc==4.0.1", "mozilla-django-oidc==4.0.1",
"livekit-api==0.7.0", "livekit-api==0.7.0",
"minio==7.2.9",
"openai==1.51.2"
] ]
[project.urls] [project.urls]
@@ -78,13 +76,13 @@ dev = [
"pylint-django==2.5.5", "pylint-django==2.5.5",
"pylint==3.2.7", "pylint==3.2.7",
"pytest-cov==5.0.0", "pytest-cov==5.0.0",
"pytest-django==4.9.0", "pytest-django==4.8.0",
"pytest==8.3.3", "pytest==8.3.2",
"pytest-icdiff==0.9", "pytest-icdiff==0.9",
"pytest-xdist==3.6.1", "pytest-xdist==3.6.1",
"responses==0.25.3", "responses==0.25.3",
"ruff==0.6.5", "ruff==0.6.3",
"types-requests==2.32.0.20240914", "types-requests==2.32.0.20240712",
] ]
[tool.setuptools] [tool.setuptools]
+1 -1
View File
@@ -32,7 +32,7 @@ WORKDIR /home/frontend
RUN npm run build RUN npm run build
# ---- Front-end image ---- # ---- 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 # Un-privileged user running the application
ARG DOCKER_USER ARG DOCKER_USER
+1 -1
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/play-icon.svg" /> <link rel="icon" type="image/svg+xml" href="/play-icon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Visio</title> <title>Meet</title>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
+2842 -2555
View File
File diff suppressed because it is too large Load Diff
+29 -32
View File
@@ -1,7 +1,7 @@
{ {
"name": "meet", "name": "meet",
"private": true, "private": true,
"version": "0.1.7", "version": "0.1.5",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "panda codegen && vite", "dev": "panda codegen && vite",
@@ -13,48 +13,45 @@
"check": "prettier --check ./src" "check": "prettier --check ./src"
}, },
"dependencies": { "dependencies": {
"@livekit/components-react": "2.6.5", "@livekit/components-react": "2.3.3",
"@livekit/components-styles": "1.1.3", "@livekit/components-styles": "1.0.12",
"@livekit/track-processors": "0.3.2", "@pandacss/preset-panda": "0.41.0",
"@pandacss/preset-panda": "0.46.1", "@react-aria/toast": "3.0.0-beta.15",
"@react-aria/toast": "3.0.0-beta.16",
"@remixicon/react": "4.2.0", "@remixicon/react": "4.2.0",
"@tanstack/react-query": "5.59.4", "@tanstack/react-query": "5.49.2",
"crisp-sdk-web": "1.0.25",
"hoofd": "1.7.1", "hoofd": "1.7.1",
"i18next": "23.15.2", "i18next": "23.12.1",
"i18next-browser-languagedetector": "8.0.0", "i18next-browser-languagedetector": "8.0.0",
"i18next-parser": "9.0.2", "i18next-parser": "9.0.0",
"i18next-resources-to-backend": "1.2.1", "i18next-resources-to-backend": "1.2.1",
"livekit-client": "2.5.7", "livekit-client": "2.3.1",
"posthog-js": "1.167.0", "react": "18.2.0",
"react": "18.3.1", "react-aria-components": "1.2.1",
"react-aria-components": "1.4.0", "react-dom": "18.2.0",
"react-dom": "18.3.1", "react-i18next": "14.1.3",
"react-i18next": "15.0.2",
"use-sound": "4.0.3", "use-sound": "4.0.3",
"valtio": "2.0.0", "valtio": "1.13.2",
"wouter": "3.3.5" "wouter": "3.3.0"
}, },
"devDependencies": { "devDependencies": {
"@pandacss/dev": "0.46.1", "@pandacss/dev": "0.41.0",
"@tanstack/eslint-plugin-query": "5.59.2", "@tanstack/eslint-plugin-query": "5.49.1",
"@tanstack/react-query-devtools": "5.59.4", "@tanstack/react-query-devtools": "5.49.2",
"@types/node": "20.16.11", "@types/node": "20.14.9",
"@types/react": "18.3.11", "@types/react": "18.3.3",
"@types/react-dom": "18.3.0", "@types/react-dom": "18.3.0",
"@typescript-eslint/eslint-plugin": "8.8.1", "@typescript-eslint/eslint-plugin": "7.13.1",
"@typescript-eslint/parser": "8.8.1", "@typescript-eslint/parser": "7.13.1",
"@vitejs/plugin-react": "4.3.2", "@vitejs/plugin-react": "4.3.1",
"eslint": "8.57.0", "eslint": "8.57.0",
"eslint-config-prettier": "9.1.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-hooks": "4.6.2",
"eslint-plugin-react-refresh": "0.4.12", "eslint-plugin-react-refresh": "0.4.7",
"postcss": "8.4.47", "postcss": "8.4.39",
"prettier": "3.3.3", "prettier": "3.3.3",
"typescript": "5.6.3", "typescript": "5.5.2",
"vite": "5.4.8", "vite": "5.3.1",
"vite-tsconfig-paths": "5.0.1" "vite-tsconfig-paths": "4.3.2"
} }
} }
+9 -9
View File
@@ -64,16 +64,16 @@ const config: Config = {
'100%': { boxShadow: '0 0 0 0 rgba(255, 255, 255, 0)' }, '100%': { boxShadow: '0 0 0 0 rgba(255, 255, 255, 0)' },
}, },
active_speaker: { active_speaker: {
'0%': { height: '25%' }, '0%': { height: '4px' },
'25%': { height: '45%' }, '25%': { height: '8px' },
'50%': { height: '20%' }, '50%': { height: '6px' },
'100%': { height: '55%' }, '100%': { height: '16px' },
}, },
active_speaker_small: { active_speake_small: {
'0%': { height: '20%' }, '0%': { height: '4px' },
'25%': { height: '25%' }, '25%': { height: '6px' },
'50%': { height: '18%' }, '50%': { height: '4px' },
'100%': { height: '25%' }, '100%': { height: '8px' },
}, },
wave_hand: { wave_hand: {
'0%': { transform: 'rotate(0deg)' }, '0%': { transform: 'rotate(0deg)' },
+5 -2
View File
@@ -11,15 +11,18 @@ import { Layout } from './layout/Layout'
import { NotFoundScreen } from './components/NotFoundScreen' import { NotFoundScreen } from './components/NotFoundScreen'
import { routes } from './routes' import { routes } from './routes'
import './i18n/init' import './i18n/init'
import { silenceLiveKitLogs } from '@/utils/livekit.ts'
import { queryClient } from '@/api/queryClient' import { queryClient } from '@/api/queryClient'
import { AppInitialization } from '@/components/AppInitialization'
function App() { function App() {
const { i18n } = useTranslation() const { i18n } = useTranslation()
useLang(i18n.language) useLang(i18n.language)
const isProduction = import.meta.env.PROD
silenceLiveKitLogs(isProduction)
return ( return (
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<AppInitialization />
<Suspense fallback={null}> <Suspense fallback={null}>
<I18nProvider locale={i18n.language}> <I18nProvider locale={i18n.language}>
<Layout> <Layout>
-1
View File
@@ -1,5 +1,4 @@
export const keys = { export const keys = {
user: 'user', user: 'user',
room: 'room', 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 { css } from '@/styled-system/css'
import { RiExternalLinkLine } from '@remixicon/react' import { RiExternalLinkLine } from '@remixicon/react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { LinkButton } from '@/primitives'
export const Feedback = () => { export const Feedback = () => {
const { t } = useTranslation() const { t } = useTranslation()
return ( return (
<LinkButton <Button
href="https://grist.incubateur.net/o/docs/forms/1YrfNP1QSSy8p2gCxMFnSf/4" href="https://grist.incubateur.net/o/docs/forms/1YrfNP1QSSy8p2gCxMFnSf/4"
variant="success" variant="success"
target="_blank" target="_blank"
@@ -20,6 +20,6 @@ export const Feedback = () => {
className={css({ marginLeft: 0.5 })} className={css({ marginLeft: 0.5 })}
aria-hidden="true" aria-hidden="true"
/> />
</LinkButton> </Button>
) )
} }
File diff suppressed because one or more lines are too long
@@ -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 // make sure to not resolve the promise while trying to silent login
// so that consumers of fetchUser don't think the work already ended // so that consumers of fetchUser don't think the work already ended
if (canAttemptSilentLogin()) { if (canAttemptSilentLogin()) {
attemptSilentLogin(300) attemptSilentLogin(3600)
} else { } else {
resolve(false) resolve(false)
} }
+2 -12
View File
@@ -2,12 +2,9 @@ import { useQuery } from '@tanstack/react-query'
import { keys } from '@/api/queryKeys' import { keys } from '@/api/queryKeys'
import { fetchUser } from './fetchUser' import { fetchUser } from './fetchUser'
import { type ApiUser } from './ApiUser' 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 * `isLoggedIn` is undefined while query is loading and true/false when it's done
*/ */
@@ -15,16 +12,9 @@ export const useUser = () => {
const query = useQuery({ const query = useQuery({
queryKey: [keys.user], queryKey: [keys.user],
queryFn: fetchUser, queryFn: fetchUser,
staleTime: Infinity, staleTime: 1000 * 60 * 60, // 1 hour
}) })
useEffect(() => {
if (query?.data) {
startAnalyticsSession(query.data)
initializeSupportSession(query.data)
}
}, [query.data])
const isLoggedIn = const isLoggedIn =
query.status === 'success' ? query.data !== false : undefined query.status === 'success' ? query.data !== false : undefined
const isLoggedOut = isLoggedIn === false const isLoggedOut = isLoggedIn === false
@@ -23,7 +23,7 @@ export const JoinMeetingDialog = () => {
name="roomId" name="roomId"
label={t('joinInputLabel')} label={t('joinInputLabel')}
description={t('joinInputExample', { description={t('joinInputExample', {
example: 'https://visio.numerique.gouv.fr/azer-tyu-qsdf', example: 'https://meet.numerique.gouv.fr/azer-tyu-qsdf',
})} })}
validate={(value) => { validate={(value) => {
return !isRoomValid(value.trim()) ? ( return !isRoomValid(value.trim()) ? (
@@ -44,7 +44,6 @@ export const LaterMeetingDialog = ({
setIsCopied(true) setIsCopied(true)
}} }}
onHoverChange={setIsHovered} onHoverChange={setIsHovered}
data-attr="later-dialog-copy"
> >
{isCopied ? ( {isCopied ? (
<> <>
+9 -17
View File
@@ -1,18 +1,18 @@
import { useTranslation } from 'react-i18next' 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 { Button, Menu, Text } from '@/primitives'
import { HStack } from '@/styled-system/jsx' import { HStack } from '@/styled-system/jsx'
import { navigateTo } from '@/navigation/navigateTo' import { navigateTo } from '@/navigation/navigateTo'
import { Screen } from '@/layout/Screen' import { Screen } from '@/layout/Screen'
import { Centered } from '@/layout/Centered' import { Centered } from '@/layout/Centered'
import { generateRoomId } from '@/features/rooms' import { generateRoomId } from '@/features/rooms'
import { useUser, UserAware } from '@/features/auth' import { authUrl, useUser, UserAware } from '@/features/auth'
import { JoinMeetingDialog } from '../components/JoinMeetingDialog' import { JoinMeetingDialog } from '../components/JoinMeetingDialog'
import { ProConnectButton } from '@/components/ProConnectButton'
import { useCreateRoom } from '@/features/rooms' import { useCreateRoom } from '@/features/rooms'
import { usePersistentUserChoices } from '@livekit/components-react' import { usePersistentUserChoices } from '@livekit/components-react'
import { menuItemRecipe } from '@/primitives/menuItemRecipe' import { menuItemRecipe } from '@/primitives/menuItemRecipe'
import { RiAddLine, RiLink } from '@remixicon/react' import { RiAddLine, RiLink } from '@remixicon/react'
import { MenuItem, Menu as RACMenu } from 'react-aria-components'
import { LaterMeetingDialog } from '@/features/home/components/LaterMeetingDialog' import { LaterMeetingDialog } from '@/features/home/components/LaterMeetingDialog'
import { useState } from 'react' import { useState } from 'react'
@@ -42,12 +42,10 @@ export const Home = () => {
{t('loginToCreateMeeting')} {t('loginToCreateMeeting')}
</Text> </Text>
)} )}
<HStack gap="gutter" alignItems="start"> <HStack gap="gutter">
{isLoggedIn ? ( {isLoggedIn ? (
<Menu> <Menu>
<Button variant="primary" data-attr="create-meeting"> <Button variant="primary">{t('createMeeting')}</Button>
{t('createMeeting')}
</Button>
<RACMenu> <RACMenu>
<MenuItem <MenuItem
className={menuItemRecipe({ icon: true })} className={menuItemRecipe({ icon: true })}
@@ -59,7 +57,6 @@ export const Home = () => {
}) })
) )
}} }}
data-attr="create-option-instant"
> >
<RiAddLine size={18} /> <RiAddLine size={18} />
{t('createMenu.instantOption')} {t('createMenu.instantOption')}
@@ -72,7 +69,6 @@ export const Home = () => {
setLaterRoomId(data.slug) setLaterRoomId(data.slug)
) )
}} }}
data-attr="create-option-later"
> >
<RiLink size={18} /> <RiLink size={18} />
{t('createMenu.laterOption')} {t('createMenu.laterOption')}
@@ -80,16 +76,12 @@ export const Home = () => {
</RACMenu> </RACMenu>
</Menu> </Menu>
) : ( ) : (
<ProConnectButton /> <Button variant="primary" href={authUrl()}>
{t('login', { ns: 'global' })}
</Button>
)} )}
<DialogTrigger> <DialogTrigger>
<Button <Button variant="primary" outline>
variant="primary"
outline
style={{
height: !isLoggedIn ? '56px' : undefined, // Temporary, Align with ProConnect Button fixed height
}}
>
{t('joinMeeting')} {t('joinMeeting')}
</Button> </Button>
<JoinMeetingDialog /> <JoinMeetingDialog />
@@ -1,6 +1,6 @@
import { useEffect } from 'react' import { useEffect } from 'react'
import { useRoomContext } from '@livekit/components-react' import { useRoomContext } from '@livekit/components-react'
import { Participant, RoomEvent } from 'livekit-client' import { Participant, RemoteParticipant, RoomEvent } from 'livekit-client'
import { ToastProvider, toastQueue } from './components/ToastProvider' import { ToastProvider, toastQueue } from './components/ToastProvider'
import { NotificationType } from './NotificationType' import { NotificationType } from './NotificationType'
import { Div } from '@/primitives' import { Div } from '@/primitives'
@@ -34,48 +34,47 @@ export const MainNotificationToast = () => {
}, [room, triggerNotificationSound]) }, [room, triggerNotificationSound])
useEffect(() => { useEffect(() => {
const removeParticipantNotifications = (participant: Participant) => { const removeJoinNotification = (participant: Participant) => {
toastQueue.visibleToasts.forEach((toast) => { const existingToast = toastQueue.visibleToasts.find(
if (toast.content.participant === participant) { (toast) =>
toastQueue.close(toast.key) toast.content.participant === participant &&
} toast.content.type === NotificationType.Joined
})
}
room.on(RoomEvent.ParticipantDisconnected, removeParticipantNotifications)
return () => {
room.off(
RoomEvent.ParticipantDisconnected,
removeParticipantNotifications
) )
if (existingToast) {
toastQueue.close(existingToast.key)
}
}
room.on(RoomEvent.ParticipantDisconnected, removeJoinNotification)
return () => {
room.off(RoomEvent.ParticipantConnected, removeJoinNotification)
} }
}, [room]) }, [room])
// fixme - close all related toasters when hands are lowered remotely
useEffect(() => { useEffect(() => {
const decoder = new TextDecoder()
const handleNotificationReceived = ( const handleNotificationReceived = (
prevMetadataStr: string | undefined, payload: Uint8Array,
participant: Participant participant?: RemoteParticipant
) => { ) => {
if (!participant) return if (!participant) {
if (isMobileBrowser()) return return
if (participant.isLocal) return }
if (isMobileBrowser()) {
const prevMetadata = JSON.parse(prevMetadataStr || '{}') return
const metadata = JSON.parse(participant.metadata || '{}') }
const notification = decoder.decode(payload)
if (prevMetadata.raised == metadata.raised) return
const existingToast = toastQueue.visibleToasts.find( const existingToast = toastQueue.visibleToasts.find(
(toast) => (toast) =>
toast.content.participant === participant && toast.content.participant === participant &&
toast.content.type === NotificationType.Raised toast.content.type === NotificationType.Raised
) )
if (existingToast && notification === NotificationType.Lowered) {
if (existingToast && prevMetadata.raised && !metadata.raised) {
toastQueue.close(existingToast.key) toastQueue.close(existingToast.key)
return return
} }
if (!existingToast && notification === NotificationType.Raised) {
if (!existingToast && !prevMetadata.raised && metadata.raised) {
triggerNotificationSound(NotificationType.Raised) triggerNotificationSound(NotificationType.Raised)
toastQueue.add( toastQueue.add(
{ {
@@ -87,25 +86,15 @@ export const MainNotificationToast = () => {
} }
} }
room.on(RoomEvent.ParticipantMetadataChanged, handleNotificationReceived) room.on(RoomEvent.DataReceived, handleNotificationReceived)
return () => { return () => {
room.off(RoomEvent.ParticipantMetadataChanged, handleNotificationReceived) room.off(RoomEvent.DataReceived, handleNotificationReceived)
} }
}, [room, triggerNotificationSound]) }, [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 ( return (
<Div position="absolute" bottom={0} right={5} zIndex={1000}> <Div position="absolute" bottom={20} right={5} zIndex={1000}>
<ToastProvider /> <ToastProvider />
</Div> </Div>
) )
@@ -6,7 +6,7 @@ import { HStack } from '@/styled-system/jsx'
import { Button, Div } from '@/primitives' import { Button, Div } from '@/primitives'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { RiCloseLine, RiHand } from '@remixicon/react' import { RiCloseLine, RiHand } from '@remixicon/react'
import { useSidePanel } from '@/features/rooms/livekit/hooks/useSidePanel' import { useWidgetInteraction } from '@/features/rooms/livekit/hooks/useWidgetInteraction'
export function ToastRaised({ state, ...props }: ToastProps) { export function ToastRaised({ state, ...props }: ToastProps) {
const { t } = useTranslation('notifications') const { t } = useTranslation('notifications')
@@ -17,7 +17,7 @@ export function ToastRaised({ state, ...props }: ToastProps) {
ref ref
) )
const participant = props.toast.content.participant const participant = props.toast.content.participant
const { isParticipantsOpen, toggleParticipants } = useSidePanel() const { isParticipantsOpen, toggleParticipants } = useWidgetInteraction()
return ( return (
<StyledToastContainer {...toastProps} ref={ref}> <StyledToastContainer {...toastProps} ref={ref}>
@@ -12,19 +12,6 @@ const StyledContainer = styled('div', {
justifyContent: 'center', justifyContent: 'center',
gap: '2px', 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', { const StyledChild = styled('div', {
@@ -49,32 +36,16 @@ const StyledChild = styled('div', {
}, },
size: { size: {
small: { small: {
animationName: 'active_speaker_small', animationName: 'active_speake_small',
},
},
pushToTalk: {
true: {
backgroundColor: 'primary',
width: '6px',
height: '6px',
}, },
}, },
}, },
}) })
export type ActiveSpeakerProps = { export const ActiveSpeaker = ({ isSpeaking }: { isSpeaking: boolean }) => {
isSpeaking: boolean
pushToTalk?: boolean
}
export const ActiveSpeaker = ({
isSpeaking,
pushToTalk,
}: ActiveSpeakerProps) => {
return ( return (
<StyledContainer pushToTalk={pushToTalk}> <StyledContainer>
<StyledChild <StyledChild
pushToTalk={pushToTalk}
active={isSpeaking} active={isSpeaking}
size="small" size="small"
style={{ style={{
@@ -82,14 +53,12 @@ export const ActiveSpeaker = ({
}} }}
/> />
<StyledChild <StyledChild
pushToTalk={pushToTalk}
active={isSpeaking} active={isSpeaking}
style={{ style={{
animationDelay: '100ms', animationDelay: '100ms',
}} }}
/> />
<StyledChild <StyledChild
pushToTalk={pushToTalk}
active={isSpeaking} active={isSpeaking}
size="small" size="small"
style={{ style={{
@@ -1,10 +1,15 @@
import { useEffect, useMemo, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { LiveKitRoom, type LocalUserChoices } from '@livekit/components-react' import {
formatChatMessageLinks,
LiveKitRoom,
type LocalUserChoices,
} from '@livekit/components-react'
import { Room, RoomOptions } from 'livekit-client' import { Room, RoomOptions } from 'livekit-client'
import { keys } from '@/api/queryKeys' import { keys } from '@/api/queryKeys'
import { queryClient } from '@/api/queryClient' import { queryClient } from '@/api/queryClient'
import { navigateTo } from '@/navigation/navigateTo'
import { Screen } from '@/layout/Screen' import { Screen } from '@/layout/Screen'
import { QueryAware } from '@/components/QueryAware' import { QueryAware } from '@/components/QueryAware'
import { ErrorScreen } from '@/components/ErrorScreen' import { ErrorScreen } from '@/components/ErrorScreen'
@@ -14,7 +19,6 @@ import { useCreateRoom } from '../api/createRoom'
import { InviteDialog } from './InviteDialog' import { InviteDialog } from './InviteDialog'
import { VideoConference } from '../livekit/prefabs/VideoConference' import { VideoConference } from '../livekit/prefabs/VideoConference'
import posthog from 'posthog-js'
export const Conference = ({ export const Conference = ({
roomId, roomId,
@@ -27,9 +31,6 @@ export const Conference = ({
mode?: 'join' | 'create' mode?: 'join' | 'create'
initialRoomData?: ApiRoom initialRoomData?: ApiRoom
}) => { }) => {
useEffect(() => {
posthog.capture('visit-room', { slug: roomId })
}, [roomId])
const fetchKey = [keys.room, roomId, userConfig.username] const fetchKey = [keys.room, roomId, userConfig.username]
const { const {
@@ -78,6 +79,25 @@ export const Conference = ({
const [showInviteDialog, setShowInviteDialog] = useState(mode === 'create') 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') const { t } = useTranslation('rooms')
if (isCreateError) { if (isCreateError) {
// this error screen should be replaced by a proper waiting room for anonymous user. // this error screen should be replaced by a proper waiting room for anonymous user.
@@ -100,7 +120,7 @@ export const Conference = ({
audio={userConfig.audioEnabled} audio={userConfig.audioEnabled}
video={userConfig.videoEnabled} video={userConfig.videoEnabled}
> >
<VideoConference /> <VideoConference chatMessageFormatter={formatChatMessageLinks} />
{showInviteDialog && ( {showInviteDialog && (
<InviteDialog <InviteDialog
isOpen={showInviteDialog} isOpen={showInviteDialog}
@@ -90,7 +90,6 @@ export const InviteDialog = ({
setIsCopied(true) setIsCopied(true)
}} }}
onHoverChange={setIsHovered} onHoverChange={setIsHovered}
data-attr="share-dialog-copy"
> >
{isCopied ? ( {isCopied ? (
<> <>
@@ -16,8 +16,8 @@ export const Join = ({
<PreJoin <PreJoin
persistUserChoices persistUserChoices
onSubmit={onSubmit} onSubmit={onSubmit}
micLabel={t('join.audioinput.label')} micLabel={t('join.micLabel')}
camLabel={t('join.videoinput.label')} camLabel={t('join.camlabel')}
joinLabel={t('join.joinLabel')} joinLabel={t('join.joinLabel')}
userLabel={t('join.userLabel')} userLabel={t('join.userLabel')}
/> />
@@ -11,7 +11,6 @@ export const useLowerHandParticipants = () => {
) )
return Promise.all(promises) return Promise.all(promises)
} catch (error) { } catch (error) {
console.error('An error occurred while lowering hands :', error)
throw new Error('An error occurred while lowering hands.') throw new Error('An error occurred while lowering hands.')
} }
} }
@@ -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,22 +1,13 @@
import { useRoomContext } from '@livekit/components-react' import { useRoomContext } from '@livekit/components-react'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { RoomEvent } from 'livekit-client' import { RoomEvent } from 'livekit-client'
import { egressStore } from '@/stores/egress.ts'
import { useSnapshot } from 'valtio'
export const RecordingIndicator = () => { export const RecordingIndicator = () => {
const room = useRoomContext() const room = useRoomContext()
const [isRecording, setIsRecording] = useState(room.isRecording) const [isRecording, setIsRecording] = useState(room.isRecording)
const egressSnap = useSnapshot(egressStore)
const egressIsStopping = egressSnap.egressIsStopping
useEffect(() => { useEffect(() => {
const handleRecordingStatusChanges = (isRecording: boolean) => { const handleRecordingStatusChanges = (isRecording: boolean) => {
if (!isRecording) {
egressStore.egressIsStopping = false
}
setIsRecording(isRecording) setIsRecording(isRecording)
} }
room.on(RoomEvent.RecordingStatusChanged, handleRecordingStatusChanges) room.on(RoomEvent.RecordingStatusChanged, handleRecordingStatusChanges)
@@ -25,18 +16,6 @@ export const RecordingIndicator = () => {
} }
}, [room]) }, [room])
const getStatus = () => {
if (egressIsStopping) {
return 'saving recording'
}
if (isRecording) {
return 'recording'
}
if (!isRecording) {
return 'available'
}
}
return ( return (
<div <div
style={{ style={{
@@ -47,7 +26,7 @@ export const RecordingIndicator = () => {
width: '100%', width: '100%',
}} }}
> >
Room status: {getStatus()} Room is recording: {isRecording ? 'yes' : 'no'}
</div> </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,17 +1,13 @@
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { RiChat1Line } from '@remixicon/react' import { RiChat1Line } from '@remixicon/react'
import { useSnapshot } from 'valtio'
import { css } from '@/styled-system/css'
import { ToggleButton } from '@/primitives' import { ToggleButton } from '@/primitives'
import { chatStore } from '@/stores/chat' import { css } from '@/styled-system/css'
import { useSidePanel } from '../../hooks/useSidePanel' import { useWidgetInteraction } from '../../hooks/useWidgetInteraction'
export const ChatToggle = () => { export const ChatToggle = () => {
const { t } = useTranslation('rooms', { keyPrefix: 'controls.chat' }) const { t } = useTranslation('rooms')
const chatSnap = useSnapshot(chatStore) const { isChatOpen, unreadMessages, toggleChat } = useWidgetInteraction()
const { isChatOpen, toggleChat } = useSidePanel()
const tooltipLabel = isChatOpen ? 'open' : 'closed' const tooltipLabel = isChatOpen ? 'open' : 'closed'
return ( return (
@@ -24,15 +20,14 @@ export const ChatToggle = () => {
<ToggleButton <ToggleButton
square square
legacyStyle legacyStyle
aria-label={t(tooltipLabel)} aria-label={t(`controls.chat.${tooltipLabel}`)}
tooltip={t(tooltipLabel)} tooltip={t(`controls.chat.${tooltipLabel}`)}
isSelected={isChatOpen} isSelected={isChatOpen}
onPress={() => toggleChat()} onPress={() => toggleChat()}
data-attr={`controls-chat-${tooltipLabel}`}
> >
<RiChat1Line /> <RiChat1Line />
</ToggleButton> </ToggleButton>
{!!chatSnap.unreadMessages && ( {!!unreadMessages && (
<div <div
className={css({ className={css({
position: 'absolute', position: 'absolute',
@@ -4,16 +4,30 @@ import { ToggleButton } from '@/primitives'
import { css } from '@/styled-system/css' import { css } from '@/styled-system/css'
import { useRoomContext } from '@livekit/components-react' import { useRoomContext } from '@livekit/components-react'
import { useRaisedHand } from '@/features/rooms/livekit/hooks/useRaisedHand' import { useRaisedHand } from '@/features/rooms/livekit/hooks/useRaisedHand'
import { NotificationType } from '@/features/notifications/NotificationType'
export const HandToggle = () => { export const HandToggle = () => {
const { t } = useTranslation('rooms', { keyPrefix: 'controls.hand' }) const { t } = useTranslation('rooms')
const room = useRoomContext() const room = useRoomContext()
const { isHandRaised, toggleRaisedHand } = useRaisedHand({ const { isHandRaised, toggleRaisedHand } = useRaisedHand({
participant: room.localParticipant, participant: room.localParticipant,
}) })
const tooltipLabel = isHandRaised ? 'lower' : 'raise' const label = isHandRaised
? t('controls.hand.lower')
: t('controls.hand.raise')
const notifyOtherParticipants = (isHandRaised: boolean) => {
room.localParticipant.publishData(
new TextEncoder().encode(
!isHandRaised ? NotificationType.Raised : NotificationType.Lowered
),
{
reliable: true,
}
)
}
return ( return (
<div <div
@@ -25,11 +39,13 @@ export const HandToggle = () => {
<ToggleButton <ToggleButton
square square
legacyStyle legacyStyle
aria-label={t(tooltipLabel)} aria-label={label}
tooltip={t(tooltipLabel)} tooltip={label}
isSelected={isHandRaised} isSelected={isHandRaised}
onPress={() => toggleRaisedHand()} onPress={() => {
data-attr={`controls-hand-${tooltipLabel}`} notifyOtherParticipants(isHandRaised)
toggleRaisedHand()
}}
> >
<RiHand /> <RiHand />
</ToggleButton> </ToggleButton>
@@ -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,14 @@
import { menuItemRecipe } from '@/primitives/menuItemRecipe' import { menuItemRecipe } from '@/primitives/menuItemRecipe'
import { import {
RiAccountBoxLine, RiFeedbackLine,
RiMegaphoneLine, RiQuestionLine,
RiSettings3Line, RiSettings3Line,
} from '@remixicon/react' } 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 { useTranslation } from 'react-i18next'
import { Dispatch, SetStateAction } from 'react' import { Dispatch, SetStateAction } from 'react'
import { DialogState } from './OptionsButton' import { DialogState } from '@/features/rooms/livekit/components/controls/Options/OptionsButton'
import { Separator } from '@/primitives/Separator' import { RecordingMenuItem } from './RecordingMenuItem.tsx'
import { useSidePanel } from '../../../hooks/useSidePanel'
import { RecordingMenuItem } from '@/features/rooms/livekit/components/controls/Options/RecordingMenuItem'
// @todo try refactoring it to use MenuList component // @todo try refactoring it to use MenuList component
export const OptionsMenuItems = ({ export const OptionsMenuItems = ({
@@ -18,8 +16,8 @@ export const OptionsMenuItems = ({
}: { }: {
onOpenDialog: Dispatch<SetStateAction<DialogState>> onOpenDialog: Dispatch<SetStateAction<DialogState>>
}) => { }) => {
const { t } = useTranslation('rooms', { keyPrefix: 'options.items' }) const { t } = useTranslation('rooms')
const { toggleEffects } = useSidePanel()
return ( return (
<RACMenu <RACMenu
style={{ style={{
@@ -27,34 +25,30 @@ export const OptionsMenuItems = ({
width: '300px', width: '300px',
}} }}
> >
<Section> <MenuItem
<MenuItem href="https://tchap.gouv.fr/#/room/!aGImQayAgBLjSBycpm:agent.dinum.tchap.gouv.fr?via=agent.dinum.tchap.gouv.fr"
onAction={() => toggleEffects()} target="_blank"
className={menuItemRecipe({ icon: true })} className={menuItemRecipe({ icon: true })}
> >
<RiAccountBoxLine size={20} /> <RiQuestionLine size={18} />
{t('effects')} {t('options.items.support')}
</MenuItem> </MenuItem>
<RecordingMenuItem /> <MenuItem
</Section> href="https://grist.incubateur.net/o/docs/forms/1YrfNP1QSSy8p2gCxMFnSf/4"
<Separator /> target="_blank"
<Section> className={menuItemRecipe({ icon: true })}
<MenuItem >
href="https://grist.incubateur.net/o/docs/forms/1YrfNP1QSSy8p2gCxMFnSf/4" <RiFeedbackLine size={18} />
target="_blank" {t('options.items.feedbacks')}
className={menuItemRecipe({ icon: true })} </MenuItem>
> <MenuItem
<RiMegaphoneLine size={20} /> className={menuItemRecipe({ icon: true })}
{t('feedbacks')} onAction={() => onOpenDialog('settings')}
</MenuItem> >
<MenuItem <RiSettings3Line size={18} />
className={menuItemRecipe({ icon: true })} {t('options.items.settings')}
onAction={() => onOpenDialog('settings')} </MenuItem>
> <RecordingMenuItem />
<RiSettings3Line size={20} />
{t('settings')}
</MenuItem>
</Section>
</RACMenu> </RACMenu>
) )
} }
@@ -3,7 +3,7 @@ import { menuItemRecipe } from '@/primitives/menuItemRecipe.ts'
import { RiPauseCircleLine, RiRecordCircleLine } from '@remixicon/react' import { RiPauseCircleLine, RiRecordCircleLine } from '@remixicon/react'
import { useRecordRoom } from '@/features/rooms/livekit/api/recordRoom.ts' import { useRecordRoom } from '@/features/rooms/livekit/api/recordRoom.ts'
import { useState } from 'react' import { useState } from 'react'
import { egressStore } from '@/stores/egress.ts' import { egressStore } from '@/stores/egress.tsx'
import { useSnapshot } from 'valtio' import { useSnapshot } from 'valtio'
export const RecordingMenuItem = () => { export const RecordingMenuItem = () => {
@@ -17,7 +17,6 @@ export const RecordingMenuItem = () => {
const handleAction = async () => { const handleAction = async () => {
if (egressId) { if (egressId) {
setIsPending(true) setIsPending(true)
egressStore.egressIsStopping = true
const response = await stopRecordingRoom(egressId) const response = await stopRecordingRoom(egressId)
console.log(response) console.log(response)
egressStore.egressId = undefined egressStore.egressId = undefined
@@ -39,12 +38,12 @@ export const RecordingMenuItem = () => {
{egressId ? ( {egressId ? (
<> <>
<RiPauseCircleLine size={18} /> <RiPauseCircleLine size={18} />
Arrêter l'enregistrement Stop recording room
</> </>
) : ( ) : (
<> <>
<RiRecordCircleLine size={18} /> <RiRecordCircleLine size={18} />
Enregistrer la réunion Record room
</> </>
)} )}
</MenuItem> </MenuItem>
@@ -73,7 +73,6 @@ export const HandRaisedListItem = ({
size="sm" size="sm"
onPress={() => lowerHandParticipant(participant)} onPress={() => lowerHandParticipant(participant)}
tooltip={t('participants.lowerParticipantHand', { name })} tooltip={t('participants.lowerParticipantHand', { name })}
data-attr="participants-lower-hand"
> >
<RiHand /> <RiHand />
</Button> </Button>
@@ -19,7 +19,6 @@ export const LowerAllHandsButton = ({
fullWidth fullWidth
variant="text" variant="text"
onPress={() => lowerHandParticipants(participants)} onPress={() => lowerHandParticipants(participants)}
data-attr="participants-lower-hands"
> >
{t('participants.lowerParticipantsHand')} {t('participants.lowerParticipantsHand')}
</Button> </Button>
@@ -78,7 +78,6 @@ const MicIndicator = ({ participant }: MicIndicatorProps) => {
? muteParticipant(participant) ? muteParticipant(participant)
: setIsAlertOpen(true) : setIsAlertOpen(true)
} }
data-attr="participants-mute"
> >
{isMuted ? ( {isMuted ? (
<RiMicOffFill color={'gray'} /> <RiMicOffFill color={'gray'} />
@@ -1,21 +1,28 @@
import { css } from '@/styled-system/css' import { css } from '@/styled-system/css'
import { useParticipants } from '@livekit/components-react' import { useParticipants } from '@livekit/components-react'
import { Div, H } from '@/primitives' import { Heading } from 'react-aria-components'
import { Box, Button, Div, H } from '@/primitives'
import { text } from '@/primitives/Text'
import { RiCloseLine } from '@remixicon/react'
import { participantsStore } from '@/stores/participants'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { ParticipantListItem } from '../../controls/Participants/ParticipantListItem' import { allParticipantRoomEvents } from '@/features/rooms/livekit/constants/events'
import { ParticipantsCollapsableList } from '../../controls/Participants/ParticipantsCollapsableList' import { ParticipantListItem } from '@/features/rooms/livekit/components/controls/Participants/ParticipantListItem'
import { HandRaisedListItem } from '../../controls/Participants/HandRaisedListItem' import { ParticipantsCollapsableList } from '@/features/rooms/livekit/components/controls/Participants/ParticipantsCollapsableList'
import { LowerAllHandsButton } from '../../controls/Participants/LowerAllHandsButton' import { HandRaisedListItem } from '@/features/rooms/livekit/components/controls/Participants/HandRaisedListItem'
import { LowerAllHandsButton } from '@/features/rooms/livekit/components/controls/Participants/LowerAllHandsButton'
// TODO: Optimize rendering performance, especially for longer participant lists, even though they are generally short. // TODO: Optimize rendering performance, especially for longer participant lists, even though they are generally short.
export const ParticipantsList = () => { 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, // 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 // 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. // 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 const sortedRemoteParticipants = participants
.slice(1) .slice(1)
@@ -37,40 +44,75 @@ export const ParticipantsList = () => {
// TODO - extract inline styling in a centralized styling file, and avoid magic numbers // TODO - extract inline styling in a centralized styling file, and avoid magic numbers
return ( return (
<Div overflowY="scroll"> <Box
<H size="sm"
lvl={2} minWidth="360px"
className={css({ className={css({
fontSize: '0.875rem', overflow: 'hidden',
fontWeight: 'bold', display: 'flex',
color: '#5f6368', flexDirection: 'column',
padding: '0 1.5rem', margin: '1.5rem 1.5rem 1.5rem 0',
marginBottom: '0.83em', padding: 0,
})} gap: 0,
})}
>
<Heading
slot="title"
level={1}
className={text({ variant: 'h2' })}
style={{
paddingLeft: '1.5rem',
paddingTop: '1rem',
}}
> >
{t('subheading').toUpperCase()} {t('participants.heading')}
</H> </Heading>
{raisedHandParticipants.length > 0 && ( <Div position="absolute" top="5" right="5">
<Div marginBottom=".9375rem"> <Button
<ParticipantsCollapsableList invisible
heading={t('raisedHands')} size="xs"
participants={raisedHandParticipants} onPress={() => (participantsStore.showParticipants = false)}
renderParticipant={(participant) => ( aria-label={t('participants.closeButton')}
<HandRaisedListItem participant={participant} /> tooltip={t('participants.closeButton')}
)} >
action={() => ( <RiCloseLine />
<LowerAllHandsButton participants={raisedHandParticipants} /> </Button>
)} </Div>
/> <Div overflowY="scroll">
</Div> <H
)} lvl={2}
<ParticipantsCollapsableList className={css({
heading={t('contributors')} fontSize: '0.875rem',
participants={sortedParticipants} fontWeight: 'bold',
renderParticipant={(participant) => ( color: '#5f6368',
<ParticipantListItem participant={participant} /> padding: '0 1.5rem',
marginBottom: '0.83em',
})}
>
{t('participants.subheading').toUpperCase()}
</H>
{raisedHandParticipants.length > 0 && (
<Div marginBottom=".9375rem">
<ParticipantsCollapsableList
heading={t('participants.raisedHands')}
participants={raisedHandParticipants}
renderParticipant={(participant) => (
<HandRaisedListItem participant={participant} />
)}
action={() => (
<LowerAllHandsButton participants={raisedHandParticipants} />
)}
/>
</Div>
)} )}
/> <ParticipantsCollapsableList
</Div> heading={t('participants.contributors')}
participants={sortedParticipants}
renderParticipant={(participant) => (
<ParticipantListItem participant={participant} />
)}
/>
</Div>
</Box>
) )
} }
@@ -3,10 +3,10 @@ import { RiGroupLine, RiInfinityLine } from '@remixicon/react'
import { ToggleButton } from '@/primitives' import { ToggleButton } from '@/primitives'
import { css } from '@/styled-system/css' import { css } from '@/styled-system/css'
import { useParticipants } from '@livekit/components-react' import { useParticipants } from '@livekit/components-react'
import { useSidePanel } from '../../../hooks/useSidePanel' import { useWidgetInteraction } from '../../../hooks/useWidgetInteraction'
export const ParticipantsToggle = () => { export const ParticipantsToggle = () => {
const { t } = useTranslation('rooms', { keyPrefix: 'controls.participants' }) const { t } = useTranslation('rooms')
/** /**
* Context could not be used due to inconsistent refresh behavior. * Context could not be used due to inconsistent refresh behavior.
@@ -16,7 +16,7 @@ export const ParticipantsToggle = () => {
const participants = useParticipants() const participants = useParticipants()
const numParticipants = participants?.length const numParticipants = participants?.length
const { isParticipantsOpen, toggleParticipants } = useSidePanel() const { isParticipantsOpen, toggleParticipants } = useWidgetInteraction()
const tooltipLabel = isParticipantsOpen ? 'open' : 'closed' const tooltipLabel = isParticipantsOpen ? 'open' : 'closed'
@@ -30,11 +30,10 @@ export const ParticipantsToggle = () => {
<ToggleButton <ToggleButton
square square
legacyStyle legacyStyle
aria-label={t(tooltipLabel)} aria-label={t(`controls.participants.${tooltipLabel}`)}
tooltip={t(tooltipLabel)} tooltip={t(`controls.participants.${tooltipLabel}`)}
isSelected={isParticipantsOpen} isSelected={isParticipantsOpen}
onPress={() => toggleParticipants()} onPress={() => toggleParticipants()}
data-attr={`controls-participants-${tooltipLabel}`}
> >
<RiGroupLine /> <RiGroupLine />
</ToggleButton> </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,
]
@@ -7,7 +7,6 @@ type useRaisedHandProps = {
} }
export function useRaisedHand({ participant }: useRaisedHandProps) { export function useRaisedHand({ participant }: useRaisedHandProps) {
// fixme - refactor this part to rely on attributes
const { metadata } = useParticipantInfo({ participant }) const { metadata } = useParticipantInfo({ participant })
const parsedMetadata = JSON.parse(metadata || '{}') const parsedMetadata = JSON.parse(metadata || '{}')
@@ -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,
}
}
@@ -0,0 +1,34 @@
import { useLayoutContext } from '@livekit/components-react'
import { useSnapshot } from 'valtio'
import { participantsStore } from '@/stores/participants.ts'
export const useWidgetInteraction = () => {
const { dispatch, state } = useLayoutContext().widget
const participantsSnap = useSnapshot(participantsStore)
const isParticipantsOpen = participantsSnap.showParticipants
const toggleParticipants = () => {
if (dispatch && state?.showChat) {
dispatch({ msg: 'toggle_chat' })
}
participantsStore.showParticipants = !isParticipantsOpen
}
const toggleChat = () => {
if (isParticipantsOpen) {
participantsStore.showParticipants = false
}
if (dispatch) {
dispatch({ msg: 'toggle_chat' })
}
}
return {
toggleParticipants,
toggleChat,
isChatOpen: state?.showChat,
unreadMessages: state?.unreadMessages,
isParticipantsOpen,
}
}
@@ -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,22 @@ import * as React from 'react'
import { supportsScreenSharing } from '@livekit/components-core' import { supportsScreenSharing } from '@livekit/components-core'
import { import {
DisconnectButton,
LeaveIcon,
MediaDeviceMenu,
TrackToggle,
useMaybeLayoutContext, useMaybeLayoutContext,
usePersistentUserChoices, usePersistentUserChoices,
} from '@livekit/components-react' } from '@livekit/components-react'
import { mergeProps } from '@/utils/mergeProps.ts'
import { StartMediaButton } from '../components/controls/StartMediaButton' import { StartMediaButton } from '../components/controls/StartMediaButton'
import { useMediaQuery } from '../hooks/useMediaQuery' import { useMediaQuery } from '../hooks/useMediaQuery'
import { useTranslation } from 'react-i18next'
import { OptionsButton } from '../components/controls/Options/OptionsButton' import { OptionsButton } from '../components/controls/Options/OptionsButton'
import { ParticipantsToggle } from '../components/controls/Participants/ParticipantsToggle' import { ParticipantsToggle } from '@/features/rooms/livekit/components/controls/Participants/ParticipantsToggle'
import { ChatToggle } from '../components/controls/ChatToggle' import { ChatToggle } from '@/features/rooms/livekit/components/controls/ChatToggle'
import { HandToggle } from '../components/controls/HandToggle' import { HandToggle } from '@/features/rooms/livekit/components/controls/HandToggle'
import { SelectToggleDevice } from '../components/controls/SelectToggleDevice'
import { LeaveButton } from '../components/controls/LeaveButton'
import { ScreenShareToggle } from '../components/controls/ScreenShareToggle'
import { css } from '@/styled-system/css'
/** @public */ /** @public */
export type ControlBarControls = { export type ControlBarControls = {
@@ -36,7 +38,7 @@ export interface ControlBarProps extends React.HTMLAttributes<HTMLDivElement> {
controls?: ControlBarControls controls?: ControlBarControls
/** /**
* If `true`, the user's device choices will be persisted. * 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 * @defaultValue true
* @alpha * @alpha
*/ */
@@ -63,7 +65,9 @@ export function ControlBar({
variation, variation,
saveUserChoices = true, saveUserChoices = true,
onDeviceError, onDeviceError,
...props
}: ControlBarProps) { }: ControlBarProps) {
const { t } = useTranslation('rooms')
const [isChatOpen, setIsChatOpen] = React.useState(false) const [isChatOpen, setIsChatOpen] = React.useState(false)
const layoutContext = useMaybeLayoutContext() const layoutContext = useMaybeLayoutContext()
React.useEffect(() => { React.useEffect(() => {
@@ -79,8 +83,28 @@ export function ControlBar({
const defaultVariation = isTooLittleSpace ? 'minimal' : 'verbose' const defaultVariation = isTooLittleSpace ? 'minimal' : 'verbose'
variation ??= defaultVariation variation ??= defaultVariation
const showIcon = React.useMemo(
() => variation === 'minimal' || variation === 'verbose',
[variation]
)
const showText = React.useMemo(
() => variation === 'textOnly' || variation === 'verbose',
[variation]
)
const browserSupportsScreenSharing = supportsScreenSharing() 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 { const {
saveAudioInputEnabled, saveAudioInputEnabled,
saveVideoInputEnabled, saveVideoInputEnabled,
@@ -101,55 +125,73 @@ export function ControlBar({
) )
return ( return (
<div <div {...htmlProps}>
className={css({ <div className="lk-button-group">
display: 'flex', <TrackToggle
gap: '.5rem', source={Track.Source.Microphone}
alignItems: 'center', showIcon={showIcon}
justifyContent: 'center', onChange={microphoneOnChange}
padding: '.75rem', onDeviceError={(error) =>
borderTop: '1px solid var(--lk-border-color)', onDeviceError?.({ source: Track.Source.Microphone, error })
maxHeight: 'var(--lk-control-bar-height)', }
height: '80px', >
position: 'absolute', {showText && t('controls.microphone')}
backgroundColor: '#d1d5db', </TrackToggle>
bottom: 0, <div className="lk-button-group-menu">
left: 0, <MediaDeviceMenu
right: 0, kind="audioinput"
})} onActiveDeviceChange={(_kind, deviceId) =>
> saveAudioInputDeviceId(deviceId ?? '')
<SelectToggleDevice }
source={Track.Source.Microphone} />
onChange={microphoneOnChange} </div>
onDeviceError={(error) => </div>
onDeviceError?.({ source: Track.Source.Microphone, error }) <div className="lk-button-group">
} <TrackToggle
onActiveDeviceChange={(deviceId) => source={Track.Source.Camera}
saveAudioInputDeviceId(deviceId ?? '') showIcon={showIcon}
} onChange={cameraOnChange}
/> onDeviceError={(error) =>
<SelectToggleDevice onDeviceError?.({ source: Track.Source.Camera, error })
source={Track.Source.Camera} }
onChange={cameraOnChange} >
onDeviceError={(error) => {showText && t('controls.camera')}
onDeviceError?.({ source: Track.Source.Camera, error }) </TrackToggle>
} <div className="lk-button-group-menu">
onActiveDeviceChange={(deviceId) => <MediaDeviceMenu
saveVideoInputDeviceId(deviceId ?? '') kind="videoinput"
} onActiveDeviceChange={(_kind, deviceId) =>
/> saveVideoInputDeviceId(deviceId ?? '')
}
/>
</div>
</div>
{browserSupportsScreenSharing && ( {browserSupportsScreenSharing && (
<ScreenShareToggle <TrackToggle
source={Track.Source.ScreenShare}
captureOptions={{ audio: true, selfBrowserSurface: 'include' }}
showIcon={showIcon}
onChange={onScreenShareChange}
onDeviceError={(error) => onDeviceError={(error) =>
onDeviceError?.({ source: Track.Source.ScreenShare, error }) onDeviceError?.({ source: Track.Source.ScreenShare, error })
} }
/> >
{showText &&
t(
isScreenShareEnabled
? 'controls.stopScreenShare'
: 'controls.shareScreen'
)}
</TrackToggle>
)} )}
<HandToggle /> <HandToggle />
<ChatToggle /> <ChatToggle />
<ParticipantsToggle /> <ParticipantsToggle />
<OptionsButton /> <OptionsButton />
<LeaveButton /> <DisconnectButton>
{showIcon && <LeaveIcon />}
{showText && t('controls.leave')}
</DisconnectButton>
<StartMediaButton /> <StartMediaButton />
</div> </div>
) )
@@ -1,4 +1,9 @@
import type { TrackReferenceOrPlaceholder } from '@livekit/components-core' import type {
MessageDecoder,
MessageEncoder,
TrackReferenceOrPlaceholder,
WidgetState,
} from '@livekit/components-core'
import { import {
isEqualTrackRef, isEqualTrackRef,
isTrackReference, isTrackReference,
@@ -15,20 +20,23 @@ import {
GridLayout, GridLayout,
LayoutContextProvider, LayoutContextProvider,
RoomAudioRenderer, RoomAudioRenderer,
MessageFormatter,
usePinnedTracks, usePinnedTracks,
useTracks, useTracks,
useCreateLayoutContext, useCreateLayoutContext,
Chat,
} from '@livekit/components-react' } from '@livekit/components-react'
import { ControlBar } from './ControlBar' import { ControlBar } from './ControlBar'
import { styled } from '@/styled-system/jsx' import { styled } from '@/styled-system/jsx'
import { cva } from '@/styled-system/css' 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 { FocusLayout } from '../components/FocusLayout'
import { ParticipantTile } from '../components/ParticipantTile' import { ParticipantTile } from '../components/ParticipantTile'
import { SidePanel } from '../components/SidePanel' import { MainNotificationToast } from '@/features/notifications/MainNotificationToast'
import { useSidePanel } from '../hooks/useSidePanel' import { RecordingIndicator } from '@/features/rooms/livekit/components/RecordingIndicator.tsx'
import { RecordingIndicator } from '@/features/rooms/components/RecordingIndicator.tsx'
const LayoutWrapper = styled( const LayoutWrapper = styled(
'div', 'div',
@@ -37,7 +45,7 @@ const LayoutWrapper = styled(
position: 'relative', position: 'relative',
display: 'flex', display: 'flex',
width: '100%', width: '100%',
height: '100%', height: 'calc(100% - var(--lk-control-bar-height))',
}, },
}) })
) )
@@ -47,6 +55,9 @@ const LayoutWrapper = styled(
*/ */
export interface VideoConferenceProps export interface VideoConferenceProps
extends React.HTMLAttributes<HTMLDivElement> { extends React.HTMLAttributes<HTMLDivElement> {
chatMessageFormatter?: MessageFormatter
chatMessageEncoder?: MessageEncoder
chatMessageDecoder?: MessageDecoder
/** @alpha */ /** @alpha */
SettingsComponent?: React.ComponentType SettingsComponent?: React.ComponentType
} }
@@ -69,7 +80,17 @@ export interface VideoConferenceProps
* ``` * ```
* @public * @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 = const lastAutoFocusedScreenShareTrack =
React.useRef<TrackReferenceOrPlaceholder | null>(null) React.useRef<TrackReferenceOrPlaceholder | null>(null)
@@ -81,6 +102,11 @@ export function VideoConference({ ...props }: VideoConferenceProps) {
{ updateOnlyOn: [RoomEvent.ActiveSpeakersChanged], onlySubscribed: false } { updateOnlyOn: [RoomEvent.ActiveSpeakersChanged], onlySubscribed: false }
) )
const widgetUpdate = (state: WidgetState) => {
log.debug('updating widget state', state)
setWidgetState(state)
}
const layoutContext = useCreateLayoutContext() const layoutContext = useCreateLayoutContext()
const screenShareTracks = tracks const screenShareTracks = tracks
@@ -147,7 +173,8 @@ export function VideoConference({ ...props }: VideoConferenceProps) {
]) ])
/* eslint-enable react-hooks/exhaustive-deps */ /* eslint-enable react-hooks/exhaustive-deps */
const { isSidePanelOpen } = useSidePanel() const participantsSnap = useSnapshot(participantsStore)
const showParticipants = participantsSnap.showParticipants
return ( return (
<div className="lk-video-conference" {...props}> <div className="lk-video-conference" {...props}>
@@ -155,17 +182,9 @@ export function VideoConference({ ...props }: VideoConferenceProps) {
<LayoutContextProvider <LayoutContextProvider
value={layoutContext} value={layoutContext}
// onPinChange={handleFocusStateChange} // onPinChange={handleFocusStateChange}
onWidgetChange={widgetUpdate}
> >
<div <div className="lk-video-conference-inner">
// 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 /> <RecordingIndicator />
<LayoutWrapper> <LayoutWrapper>
<div <div
@@ -176,7 +195,7 @@ export function VideoConference({ ...props }: VideoConferenceProps) {
className="lk-grid-layout-wrapper" className="lk-grid-layout-wrapper"
style={{ height: 'auto' }} style={{ height: 'auto' }}
> >
<GridLayout tracks={tracks} style={{ padding: 0 }}> <GridLayout tracks={tracks}>
<ParticipantTile /> <ParticipantTile />
</GridLayout> </GridLayout>
</div> </div>
@@ -185,7 +204,7 @@ export function VideoConference({ ...props }: VideoConferenceProps) {
className="lk-focus-layout-wrapper" className="lk-focus-layout-wrapper"
style={{ height: 'auto' }} style={{ height: 'auto' }}
> >
<FocusLayoutContainer style={{ padding: 0 }}> <FocusLayoutContainer>
<CarouselLayout <CarouselLayout
tracks={carouselTracks} tracks={carouselTracks}
style={{ style={{
@@ -198,12 +217,18 @@ export function VideoConference({ ...props }: VideoConferenceProps) {
</FocusLayoutContainer> </FocusLayoutContainer>
</div> </div>
)} )}
<MainNotificationToast />
</div> </div>
<Chat
style={{ display: widgetState.showChat ? 'grid' : 'none' }}
messageFormatter={chatMessageFormatter}
messageEncoder={chatMessageEncoder}
messageDecoder={chatMessageDecoder}
/>
{showParticipants && <ParticipantsList />}
</LayoutWrapper> </LayoutWrapper>
<MainNotificationToast /> <ControlBar />
</div> </div>
<ControlBar />
<SidePanel />
</LayoutContextProvider> </LayoutContextProvider>
)} )}
<RoomAudioRenderer /> <RoomAudioRenderer />
@@ -8,7 +8,6 @@ import { ErrorScreen } from '@/components/ErrorScreen'
import { useUser, UserAware } from '@/features/auth' import { useUser, UserAware } from '@/features/auth'
import { Conference } from '../components/Conference' import { Conference } from '../components/Conference'
import { Join } from '../components/Join' import { Join } from '../components/Join'
import { useKeyboardShortcuts } from '@/features/shortcuts/useKeyboardShortcuts'
export const Room = () => { export const Room = () => {
const { isLoggedIn } = useUser() const { isLoggedIn } = useUser()
@@ -20,8 +19,6 @@ export const Room = () => {
const mode = isLoggedIn && history.state?.create ? 'create' : 'join' const mode = isLoggedIn && history.state?.create ? 'create' : 'join'
const skipJoinScreen = isLoggedIn && mode === 'create' const skipJoinScreen = isLoggedIn && mode === 'create'
useKeyboardShortcuts()
const clearRouterState = () => { const clearRouterState = () => {
if (window?.history?.state) { if (window?.history?.state) {
window.history.replaceState({}, '') window.history.replaceState({}, '')
@@ -1,8 +1,7 @@
import { Trans, useTranslation } from 'react-i18next' import { Trans, useTranslation } from 'react-i18next'
import { useLanguageLabels } from '@/i18n/useLanguageLabels' import { useLanguageLabels } from '@/i18n/useLanguageLabels'
import { A, Badge, Dialog, type DialogProps, Field, H, P } from '@/primitives' import { A, Badge, Dialog, type DialogProps, Field, H, P } from '@/primitives'
import { logoutUrl, useUser } from '@/features/auth' import { authUrl, logoutUrl, useUser } from '@/features/auth'
import { ProConnectButton } from '@/components/ProConnectButton'
export type SettingsDialogProps = Pick<DialogProps, 'isOpen' | 'onOpenChange'> export type SettingsDialogProps = Pick<DialogProps, 'isOpen' | 'onOpenChange'>
@@ -29,7 +28,9 @@ export const SettingsDialog = (props: SettingsDialogProps) => {
) : ( ) : (
<> <>
<P>{t('account.youAreNotLoggedIn')}</P> <P>{t('account.youAreNotLoggedIn')}</P>
<ProConnectButton /> <P>
<A href={authUrl()}>{t('login', { ns: 'global' })}</A>
</P>
</> </>
)} )}
<H lvl={2}>{t('language.heading')}</H> <H lvl={2}>{t('language.heading')}</H>
@@ -4,12 +4,11 @@ import {
usePersistentUserChoices, usePersistentUserChoices,
useRoomContext, useRoomContext,
} from '@livekit/components-react' } from '@livekit/components-react'
import { logoutUrl, useUser } from '@/features/auth' import { authUrl, logoutUrl, useUser } from '@/features/auth'
import { css } from '@/styled-system/css' import { css } from '@/styled-system/css'
import { TabPanel, TabPanelProps } from '@/primitives/Tabs' import { TabPanel, TabPanelProps } from '@/primitives/Tabs'
import { HStack } from '@/styled-system/jsx' import { HStack } from '@/styled-system/jsx'
import { useState } from 'react' import { useState } from 'react'
import { ProConnectButton } from '@/components/ProConnectButton'
export type AccountTabProps = Pick<DialogProps, 'onOpenChange'> & export type AccountTabProps = Pick<DialogProps, 'onOpenChange'> &
Pick<TabPanelProps, 'id'> Pick<TabPanelProps, 'id'>
@@ -59,7 +58,9 @@ export const AccountTab = ({ id, onOpenChange }: AccountTabProps) => {
) : ( ) : (
<> <>
<P>{t('account.youAreNotLoggedIn')}</P> <P>{t('account.youAreNotLoggedIn')}</P>
<ProConnectButton /> <P>
<A href={authUrl()}>{t('login', { ns: 'global' })}</A>
</P>
</> </>
)} )}
<HStack <HStack
@@ -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 { css } from '@/styled-system/css'
import { Stack } from '@/styled-system/jsx' import { Stack } from '@/styled-system/jsx'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { Text, Button } from '@/primitives' import { A, Text, Button } from '@/primitives'
import { SettingsButton } from '@/features/settings' import { SettingsButton } from '@/features/settings'
import { logoutUrl, useUser } from '@/features/auth' import { authUrl, logoutUrl, useUser } from '@/features/auth'
import { useMatchesRoute } from '@/navigation/useMatchesRoute' import { useMatchesRoute } from '@/navigation/useMatchesRoute'
import { Feedback } from '@/components/Feedback' import { Feedback } from '@/components/Feedback'
import { Menu } from '@/primitives/Menu' import { Menu } from '@/primitives/Menu'
import { MenuList } from '@/primitives/MenuList' 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 = () => { export const Header = () => {
const { t } = useTranslation() const { t } = useTranslation()
@@ -66,7 +63,7 @@ export const Header = () => {
<nav> <nav>
<Stack gap={1} direction="row" align="center"> <Stack gap={1} direction="row" align="center">
{isLoggedIn === false && !isHome && ( {isLoggedIn === false && !isHome && (
<ProConnectButton hint={false} /> <A href={authUrl()}>{t('login')}</A>
)} )}
{!!user && ( {!!user && (
<Menu> <Menu>
@@ -82,8 +79,6 @@ export const Header = () => {
items={[{ value: 'logout', label: t('logout') }]} items={[{ value: 'logout', label: t('logout') }]}
onAction={(value) => { onAction={(value) => {
if (value === 'logout') { if (value === 'logout') {
terminateAnalyticsSession()
terminateSupportSession()
window.location.href = logoutUrl() window.location.href = logoutUrl()
} }
}} }}
+2 -6
View File
@@ -1,5 +1,5 @@
{ {
"app": "Visio", "app": "Meet",
"backToHome": "", "backToHome": "",
"cancel": "", "cancel": "",
"closeDialog": "", "closeDialog": "",
@@ -12,11 +12,7 @@
}, },
"loading": "", "loading": "",
"loggedInUserTooltip": "", "loggedInUserTooltip": "",
"login": { "login": "Anmelden",
"buttonLabel": "",
"linkLabel": "",
"link": ""
},
"logout": "", "logout": "",
"notFound": { "notFound": {
"heading": "" "heading": ""
+7 -63
View File
@@ -4,27 +4,11 @@
"heading": "" "heading": ""
}, },
"join": { "join": {
"videoinput": { "camlabel": "",
"choose": "",
"disable": "",
"enable": "",
"label": "",
"placeholder": ""
},
"audioinput": {
"choose": "",
"disable": "",
"enable": "",
"label": ""
},
"heading": "", "heading": "",
"joinLabel": "", "joinLabel": "",
"joinMeeting": "", "micLabel": "",
"toggleOff": "", "userLabel": ""
"toggleOn": "",
"userLabel": "",
"usernameHint": "",
"usernameLabel": ""
}, },
"leaveRoomPrompt": "", "leaveRoomPrompt": "",
"shareDialog": { "shareDialog": {
@@ -47,25 +31,12 @@
"stopScreenShare": "", "stopScreenShare": "",
"chat": { "chat": {
"open": "", "open": "",
"closed": "", "closed": ""
"input": {
"textArea": {
"label": "",
"placeholder": ""
},
"button": {
"label": ""
}
}
}, },
"hand": { "hand": {
"raise": "", "raise": "",
"lower": "" "lower": ""
}, },
"screenShare": {
"start": "",
"stop": ""
},
"leave": "", "leave": "",
"participants": { "participants": {
"open": "", "open": "",
@@ -78,40 +49,13 @@
"feedbacks": "", "feedbacks": "",
"support": "", "support": "",
"settings": "", "settings": "",
"username": "", "username": ""
"effects": ""
} }
}, },
"effects": {
"activateCamera": "",
"notAvailable": "",
"heading": "",
"blur": {
"light": "",
"normal": "",
"apply": "",
"clear": ""
},
"experimental": ""
},
"sidePanel": {
"heading": {
"participants": "",
"effects": "",
"chat": ""
},
"content": {
"participants": "",
"effects": "",
"chat": ""
},
"closeButton": ""
},
"chat": {
"disclaimer": ""
},
"participants": { "participants": {
"heading": "",
"subheading": "", "subheading": "",
"closeButton": "",
"contributors": "", "contributors": "",
"collapsable": { "collapsable": {
"open": "", "open": "",
+3 -7
View File
@@ -1,10 +1,10 @@
{ {
"app": "Visio", "app": "Meet",
"backToHome": "Back to homescreen", "backToHome": "Back to homescreen",
"cancel": "Cancel", "cancel": "Cancel",
"closeDialog": "Close dialog", "closeDialog": "Close dialog",
"error": { "error": {
"heading": "An error occurred while loading the page" "heading": "An error occured while loading the page"
}, },
"feedbackAlert": "Give us feedback", "feedbackAlert": "Give us feedback",
"forbidden": { "forbidden": {
@@ -12,11 +12,7 @@
}, },
"loading": "Loading…", "loading": "Loading…",
"loggedInUserTooltip": "Logged in as…", "loggedInUserTooltip": "Logged in as…",
"login": { "login": "Login",
"buttonLabel": "Login with ProConnect",
"linkLabel": "What is ProConnect? - new tab",
"link": "What is ProConnect?"
},
"logout": "Logout", "logout": "Logout",
"notFound": { "notFound": {
"heading": "Page not found" "heading": "Page not found"
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"createMeeting": "Create a meeting", "createMeeting": "Create a meeting",
"heading": "Welcome in Visio", "heading": "Welcome in Meet",
"intro": "Work easily, from anywhere.", "intro": "Work easily, from anywhere.",
"joinInputError": "Use a meeting link or code. Examples:", "joinInputError": "Use a meeting link or code. Examples:",
"joinInputExample": "URL or 10-letter code", "joinInputExample": "URL or 10-letter code",
+11 -65
View File
@@ -1,30 +1,14 @@
{ {
"feedback": { "feedback": {
"body": "Please fill out the form available in the header to give us your precious feedback! Thanks.", "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": { "join": {
"videoinput": { "camlabel": "Camera",
"choose": "Select camera", "heading": "Join the meeting",
"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",
"joinLabel": "Join", "joinLabel": "Join",
"joinMeeting": "Join meeting", "micLabel": "Microphone",
"toggleOff": "Click to turn off", "userLabel": "Your name"
"toggleOn": "Click to turn on",
"userLabel": "",
"usernameHint": "Shown to other participants",
"usernameLabel": "Your name"
}, },
"leaveRoomPrompt": "This will make you leave the meeting.", "leaveRoomPrompt": "This will make you leave the meeting.",
"shareDialog": { "shareDialog": {
@@ -43,27 +27,16 @@
"controls": { "controls": {
"microphone": "Microphone", "microphone": "Microphone",
"camera": "Camera", "camera": "Camera",
"shareScreen": "Share screen",
"stopScreenShare": "Stop screen share",
"chat": { "chat": {
"open": "Close the chat", "open": "Close the chat",
"closed": "Open the chat", "closed": "Open the chat"
"input": {
"textArea": {
"label": "Enter a message",
"placeholder": "Enter a message"
},
"button": {
"label": "Send message"
}
}
}, },
"hand": { "hand": {
"raise": "Raise hand", "raise": "Raise hand",
"lower": "Lower hand" "lower": "Lower hand"
}, },
"screenShare": {
"start": "Share screen",
"stop": "Stop screen share"
},
"leave": "Leave", "leave": "Leave",
"participants": { "participants": {
"open": "Hide everyone", "open": "Hide everyone",
@@ -76,40 +49,13 @@
"feedbacks": "Give us feedbacks", "feedbacks": "Give us feedbacks",
"support": "Get Help on Tchap", "support": "Get Help on Tchap",
"settings": "Settings", "settings": "Settings",
"username": "Update Your Name", "username": "Update Your Name"
"effects": "Apply effects"
} }
}, },
"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": { "participants": {
"heading": "Participants",
"subheading": "In room", "subheading": "In room",
"closeButton": "Hide participants",
"you": "You", "you": "You",
"contributors": "Contributors", "contributors": "Contributors",
"collapsable": { "collapsable": {
+1 -1
View File
@@ -3,7 +3,7 @@
"currentlyLoggedAs": "You are currently logged in as <0>{{user}}</0>", "currentlyLoggedAs": "You are currently logged in as <0>{{user}}</0>",
"heading": "Account", "heading": "Account",
"youAreNotLoggedIn": "You are not logged in.", "youAreNotLoggedIn": "You are not logged in.",
"nameLabel": "Your Name", "nameLabel": "Votre Nom",
"authentication": "Authentication" "authentication": "Authentication"
}, },
"audio": { "audio": {
+2 -6
View File
@@ -1,5 +1,5 @@
{ {
"app": "Visio", "app": "Meet",
"backToHome": "Retour à l'accueil", "backToHome": "Retour à l'accueil",
"cancel": "Annuler", "cancel": "Annuler",
"closeDialog": "Fermer la fenêtre modale", "closeDialog": "Fermer la fenêtre modale",
@@ -12,11 +12,7 @@
}, },
"loading": "Chargement…", "loading": "Chargement…",
"loggedInUserTooltip": "Connecté en tant que…", "loggedInUserTooltip": "Connecté en tant que…",
"login": { "login": "Se connecter",
"buttonLabel": "S'identifier avec ProConnect",
"linkLabel": "Qu'est-ce que ProConnect ? - nouvelle fenêtre",
"link": "Qu'est-ce que ProConnect ?"
},
"logout": "Se déconnecter", "logout": "Se déconnecter",
"notFound": { "notFound": {
"heading": "Page introuvable" "heading": "Page introuvable"
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"createMeeting": "Créer une réunion", "createMeeting": "Créer une réunion",
"heading": "Visio", "heading": "Meet",
"intro": "Collaborez en toute simplicité, où que vous soyez.", "intro": "Collaborez en toute simplicité, où que vous soyez.",
"joinInputError": "Saisissez un lien ou un code de réunion. Exemples :", "joinInputError": "Saisissez un lien ou un code de réunion. Exemples :",
"joinInputExample": "Un code de réunion ressemble à ceci : abc-defg-hij", "joinInputExample": "Un code de réunion ressemble à ceci : abc-defg-hij",
+11 -65
View File
@@ -1,30 +1,14 @@
{ {
"feedback": { "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.", "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": { "join": {
"videoinput": { "camlabel": "Webcam",
"choose": "Choisir la webcam", "heading": "Rejoindre la réunion",
"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",
"joinLabel": "Rejoindre", "joinLabel": "Rejoindre",
"joinMeeting": "Rejoindre la réjoindre", "micLabel": "Micro",
"toggleOff": "Cliquez pour désactiver", "userLabel": "Votre nom"
"toggleOn": "Cliquez pour activer",
"userLabel": "",
"usernameHint": "Affiché aux autres participants",
"usernameLabel": "Votre nom"
}, },
"leaveRoomPrompt": "Revenir à l'accueil vous fera quitter la réunion.", "leaveRoomPrompt": "Revenir à l'accueil vous fera quitter la réunion.",
"shareDialog": { "shareDialog": {
@@ -43,27 +27,16 @@
"controls": { "controls": {
"microphone": "Microphone", "microphone": "Microphone",
"camera": "Camera", "camera": "Camera",
"shareScreen": "Partager l'écran",
"stopScreenShare": "Arrêter le partage",
"chat": { "chat": {
"open": "Masquer le chat", "open": "Masquer le chat",
"closed": "Afficher le chat", "closed": "Afficher le chat"
"input": {
"textArea": {
"label": "Ecrire un message",
"placeholder": "Ecrire un message"
},
"button": {
"label": "Envoyer un message"
}
}
}, },
"hand": { "hand": {
"raise": "Lever la main", "raise": "Lever la main",
"lower": "Baisser la main" "lower": "Baisser la main"
}, },
"screenShare": {
"start": "Partager l'écran",
"stop": "Arrêter le partage"
},
"leave": "Quitter", "leave": "Quitter",
"participants": { "participants": {
"open": "Masquer les participants", "open": "Masquer les participants",
@@ -76,40 +49,13 @@
"feedbacks": "Partager votre avis", "feedbacks": "Partager votre avis",
"support": "Obtenir de l'aide sur Tchap", "support": "Obtenir de l'aide sur Tchap",
"settings": "Paramètres", "settings": "Paramètres",
"username": "Choisir votre nom", "username": "Choisir votre nom"
"effects": "Appliquer des effets"
} }
}, },
"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": { "participants": {
"heading": "Participants",
"subheading": "Dans la réunion", "subheading": "Dans la réunion",
"closeButton": "Masquer les participants",
"you": "Vous", "you": "Vous",
"contributors": "Contributeurs", "contributors": "Contributeurs",
"collapsable": { "collapsable": {
+16 -1
View File
@@ -1,6 +1,8 @@
import { import {
Button as RACButton, Button as RACButton,
type ButtonProps as RACButtonsProps, type ButtonProps as RACButtonsProps,
Link,
LinkProps,
} from 'react-aria-components' } from 'react-aria-components'
import { type RecipeVariantProps } from '@/styled-system/css' import { type RecipeVariantProps } from '@/styled-system/css'
import { buttonRecipe, type ButtonRecipe } from './buttonRecipe' import { buttonRecipe, type ButtonRecipe } from './buttonRecipe'
@@ -10,12 +12,25 @@ export type ButtonProps = RecipeVariantProps<ButtonRecipe> &
RACButtonsProps & RACButtonsProps &
TooltipWrapperProps TooltipWrapperProps
type LinkButtonProps = RecipeVariantProps<ButtonRecipe> &
LinkProps &
TooltipWrapperProps
type ButtonOrLinkProps = ButtonProps | LinkButtonProps
export const Button = ({ export const Button = ({
tooltip, tooltip,
tooltipType = 'instant', tooltipType = 'instant',
...props ...props
}: ButtonProps) => { }: ButtonOrLinkProps) => {
const [variantProps, componentProps] = buttonRecipe.splitVariantProps(props) const [variantProps, componentProps] = buttonRecipe.splitVariantProps(props)
if ((props as LinkButtonProps).href !== undefined) {
return (
<TooltipWrapper tooltip={tooltip} tooltipType={tooltipType}>
<Link className={buttonRecipe(variantProps)} {...componentProps} />
</TooltipWrapper>
)
}
return ( return (
<TooltipWrapper tooltip={tooltip} tooltipType={tooltipType}> <TooltipWrapper tooltip={tooltip} tooltipType={tooltipType}>
+4 -5
View File
@@ -134,11 +134,10 @@ export const Checkbox = ({
> >
<StyledCheckbox {...props}> <StyledCheckbox {...props}>
{(renderProps) => { {(renderProps) => {
if (renderProps.isInvalid && !!props.validate) { renderProps.isInvalid && !!props.validate
setError(props.validate(renderProps.isSelected)) ? setError(props.validate(renderProps.isSelected))
} else { : setError(null)
setError(null)
}
return ( return (
<> <>
<div className="mt-Checkbox-checkbox"> <div className="mt-Checkbox-checkbox">
@@ -1,22 +0,0 @@
import { Link, LinkProps } from 'react-aria-components'
import { type RecipeVariantProps } from '@/styled-system/css'
import { buttonRecipe, type ButtonRecipe } from './buttonRecipe'
import { TooltipWrapper, type TooltipWrapperProps } from './TooltipWrapper'
type LinkButtonProps = RecipeVariantProps<ButtonRecipe> &
LinkProps &
TooltipWrapperProps
export const LinkButton = ({
tooltip,
tooltipType = 'instant',
...props
}: LinkButtonProps) => {
const [variantProps, componentProps] = buttonRecipe.splitVariantProps(props)
return (
<TooltipWrapper tooltip={tooltip} tooltipType={tooltipType}>
<Link className={buttonRecipe(variantProps)} {...componentProps} />
</TooltipWrapper>
)
}
+1 -1
View File
@@ -26,7 +26,7 @@ export const MenuList = <T extends string | number = string>({
const label = typeof item === 'string' ? item : item.label const label = typeof item === 'string' ? item : item.label
return ( return (
<MenuItem <MenuItem
className={menuItemRecipe({ extraPadding: true })} className={menuItemRecipe()}
key={value} key={value}
id={value as string} id={value as string}
onAction={() => { onAction={() => {
-10
View File
@@ -1,10 +0,0 @@
import { styled } from '@/styled-system/jsx'
import { Separator as RACSeparator } from 'react-aria-components'
export const Separator = styled(RACSeparator, {
base: {
height: '1px',
background: 'gray.400',
margin: '4px 0',
},
})
-27
View File
@@ -1,27 +0,0 @@
import { TextArea as RACTextArea } from 'react-aria-components'
import { styled } from '@/styled-system/jsx'
/**
* Styled RAC TextArea.
*/
export const TextArea = styled(RACTextArea, {
base: {
width: 'full',
paddingY: 0.25,
paddingX: 0.5,
border: '1px solid',
borderColor: 'control.border',
color: 'control.text',
borderRadius: 4,
transition: 'all 200ms',
},
variants: {
placeholderStyle: {
strong: {
_placeholder: {
color: 'black',
},
},
},
},
})
@@ -117,9 +117,6 @@ export const buttonRecipe = cva({
'&[data-pressed]': { '&[data-pressed]': {
borderColor: 'currentcolor', borderColor: 'currentcolor',
}, },
'&[data-disabled]': {
color: 'gray.300',
},
}, },
}, },
fullWidth: { fullWidth: {
-1
View File
@@ -9,7 +9,6 @@ export { Badge } from './Badge'
export { Bold } from './Bold' export { Bold } from './Bold'
export { Box } from './Box' export { Box } from './Box'
export { Button } from './Button' export { Button } from './Button'
export { LinkButton } from './LinkButton'
export { useCloseDialog } from './useCloseDialog' export { useCloseDialog } from './useCloseDialog'
export { Dialog, type DialogProps } from './Dialog' export { Dialog, type DialogProps } from './Dialog'
export { Div } from './Div' export { Div } from './Div'
-9
View File
@@ -1,9 +0,0 @@
import { proxy } from 'valtio'
type State = {
unreadMessages: number
}
export const chatStore = proxy<State>({
unreadMessages: 0,
})
@@ -2,10 +2,8 @@ import { proxy } from 'valtio'
type State = { type State = {
egressId: string | undefined egressId: string | undefined
egressIsStopping: boolean
} }
export const egressStore = proxy<State>({ export const egressStore = proxy<State>({
egressId: undefined, egressId: undefined,
egressIsStopping: false,
}) })
@@ -1,11 +0,0 @@
import { proxy } from 'valtio'
export type KeyboardShortcutHandler = () => void
type State = {
shortcuts: Map<string, KeyboardShortcutHandler>
}
export const keyboardShortcutsStore = proxy<State>({
shortcuts: new Map<string, KeyboardShortcutHandler>(),
})
-3
View File
@@ -1,12 +1,9 @@
import { proxy } from 'valtio' import { proxy } from 'valtio'
import { PanelId } from '@/features/rooms/livekit/hooks/useSidePanel'
type State = { type State = {
showHeader: boolean showHeader: boolean
activePanelId: PanelId | null
} }
export const layoutStore = proxy<State>({ export const layoutStore = proxy<State>({
showHeader: false, showHeader: false,
activePanelId: null,
}) })
+9
View File
@@ -0,0 +1,9 @@
import { proxy } from 'valtio'
type State = {
showParticipants: boolean
}
export const participantsStore = proxy<State>({
showParticipants: false,
})
-4
View File
@@ -25,7 +25,3 @@ export function isSafari(): boolean {
export function isLocal(p: Participant) { export function isLocal(p: Participant) {
return p instanceof LocalParticipant return p instanceof LocalParticipant
} }
export function isMacintosh() {
return navigator.platform.indexOf('Mac') > -1
}
+45 -63
View File
@@ -1,14 +1,14 @@
djangoSecretKey: ENC[AES256_GCM,data:p+9m8eNB/dKMXAdfL0cVCg1uKhAv+YLrM+jjajvRYmOZZ9qbiikuFv0dyDp32va/M9w=,iv:ijUztg7ta6BBTsKs+IIfJMFdN0DfzyAKoxlfY8lisPg=,tag:B+uW6akIV0iI2LdMQotrpw==,type:str] djangoSecretKey: ENC[AES256_GCM,data:dqeL5W5WaTir+tzUAIbivT1DE0zv1IVsq1/nTU7LRKHO2PUefwsyGAy37eYIeC89q9I=,iv:ODTaWFvvYLHNYRkIX//wY2knKD2uIwf7Mo4UhUBEX9c=,tag:MnE3wLdLcK79XUwXV99w4w==,type:str]
oidc: oidc:
clientId: ENC[AES256_GCM,data:rHzKkQwFQ7hV6kOBBP60RK41NBKVMUs4dMcZavMQ8gCu9ust,iv:8vviSb+XIKS/zjBIScfmWu0VJ8lXCQZ8p7BxuvJtA2w=,tag:k8vn8I/qxKLE/+JNTDj4Jw==,type:str] clientId: ENC[AES256_GCM,data:X+Qj//JXn9kfVszbe9BNbL8rQ53AJq0Cluq3nG58OyXlv5/y,iv:/Na7zvcsIu5OxJw1uFwiksh8tXfzG9It26jW41E0edQ=,tag:oph41uyMwj3uZMnZPI64ZQ==,type:str]
clientSecret: ENC[AES256_GCM,data:dOYJoG2PStlOMIJPi2exPzsqlxis73iTkcBMvjr8DBr2isWzstpbexscsog7Tuyelw4tpzrJKzC5BTTwJ+xioQ==,iv:oqkLRTPB8+qR0AHvjyNVfHRmoeGrkUvZjrTsWBjIeBc=,tag:hryfmSeqkdWCN9U38jxXlA==,type:str] clientSecret: ENC[AES256_GCM,data:2WV+i0pd4wBXiwG8ak+yVROGsmwSaM0X0rEJjFeNeGYtUsHozd5XrurQu6FAB1tQnzTRgRgAU0ob0MqbsMUYhg==,iv:OJZRG+OUD0Fi2HZ0w3Rn+SMFkebMrsQtKvymJkicF28=,tag:PJYzhVvM0gH0xAGrSSPAIQ==,type:str]
#ENC[AES256_GCM,data:ua1td/VBXGIHDgAw/bm8XnWIRLmgeJKX9dP7g/rNv3jVsXHw6T+iDXxMWpLXNicAZ/RTymdntlwLwsH47r70Z4icEPsjps0yOZ+X734vaL9wVH9IsyFwCihtyck94kgY4CyC7DI=,iv:iGHYu+2aPaI28PQWFheVVuge8BPWLw1VB7Afsz7eLtI=,tag:pfkXsS+/QmHb3kHS/ONHCA==,type:comment] #ENC[AES256_GCM,data:iZjWgYcHtDeK/c1TrfVVsZaJRmuc1+2XEJnLEOYw1wXMsUUvgswEc5PP/4UmirhbSulwtuMRhFt74sIogMxPl2etMAoCqo3ziKRuu3FrmgSUD/bdXQRl+VQ0nJ3NnWNcKqf+GS8=,iv:5hUaA4hkABLvXoKVE4TNZevOdUCaCy3zYkGajHpmZ5k=,tag:ikiSAyIly8NAgVA0olASRw==,type:comment]
livekit: livekit:
keys: keys:
devkey: ENC[AES256_GCM,data:5RnAMGm3,iv:bY4n8op2KFlXRqzV9h3QwoC3Bws2aEoN1GFxPlrrVBw=,tag:lA+b/6poVRzeJW6Bu8V29A==,type:str] devkey: ENC[AES256_GCM,data:deDMTTO9,iv:ZCloCUPRJ7PG2KcGsnhVj3lRDfCmJowwp8PVg4fdTmg=,tag:zCh8lQT29kQbY7LiXHNEiA==,type:str]
livekitApi: livekitApi:
key: ENC[AES256_GCM,data:JP7KkPms,iv:LlIJ62IRyGf8fByl6abSZv1to2FUc90laC0oL5HFJK4=,tag:2aLMQ79GlDOaiurh8unO0Q==,type:str] key: ENC[AES256_GCM,data:fh0zJSUE,iv:yR5siulW5vlO/Ew8qtYFNNtyODOPm3d3NL4SVCzzVcM=,tag:hLtEuonLJoi0eFJmY0RYEg==,type:str]
secret: ENC[AES256_GCM,data:kGDJo1lh,iv:dnI1OuvZGOJZEKZwzoigXqViqYCw/6H7Y0sVXH/p5RA=,tag:G1IB0mc8zuKEmkxrfyImrQ==,type:str] secret: ENC[AES256_GCM,data:81SOHzA6,iv:is7QdB/T58IP41SrPyDdFHtyX57tphb9Gpf/aQ7NnTY=,tag:1E03R+EFYmLtxFoIDcVEHg==,type:str]
sops: sops:
kms: [] kms: []
gcp_kms: [] gcp_kms: []
@@ -18,86 +18,68 @@ sops:
- recipient: age15fyxdwmg5mvldtqqus87xspuws2u0cpvwheehrtvkexj4tnsqqysw6re2x - recipient: age15fyxdwmg5mvldtqqus87xspuws2u0cpvwheehrtvkexj4tnsqqysw6re2x
enc: | enc: |
-----BEGIN AGE ENCRYPTED FILE----- -----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSByR3IybDN3eGx4amYzZkFt YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBwZG8rTjAzcVNtVHdLZmJI
OW5VV3FQN3dkSmZBL0JwUE1qSzNLYmRTc1RjCkVCQ2ZmaHk2SFRJaXdMd0VMZUlP Mm4vemNrV1dDUExEZ1R1UGJmWjN3aWw1VXc0CjF1MVhhUTV6ckl1OEFNSjBJUCta
b0VQeDVUTDBEZzhBQnhrS2RybzYvL1UKLS0tIG1CbllhWGpsOWx4WEkya0NLeUlC MTE3QU04RDJKMWlWcHhDSG1NTmZyTkEKLS0tIHh5UHRqckUxZWZLUDl4d3FDdHJs
WmRScW9MVkxQLzRxdk85WTZ4U2E0aUUKTpOPYQXutU0xYLih7SNYoQgO+PSEIERL Y0VMc0llMytmMWNUOW56d3ltTHpwZ2cKEHkn6wuHNeMTk+E6nEMpEJZ6wpdXSi3k
HLz+C7iV+Fj1/M7JrgiGxTB8wJoKMo7IhJ8AjxaAdxR4Q1TgUpQkPw== FkzXRa6SudAgA4R6K9EieHKPdiNvi0IsCJOhLpiNQtENu9poF5ozqQ==
-----END AGE ENCRYPTED FILE----- -----END AGE ENCRYPTED FILE-----
- recipient: age16hnlml8yv4ynwy0seer57g8qww075crd0g7nsundz3pj4wk7m3vqftszg7 - recipient: age16hnlml8yv4ynwy0seer57g8qww075crd0g7nsundz3pj4wk7m3vqftszg7
enc: | enc: |
-----BEGIN AGE ENCRYPTED FILE----- -----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSA0aE15QkRsNmg2UTkxaWNF YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSAva3lFTTM2ZUJpcnozNmJt
T3NZY2RqSDd0WlRKOHYxWFE2R3J5SGJhRjJNClNIcEFwOEtoSmRWQjdaSm1ZSnlj ZHAvRndkSjRCVEZYN29wMWFZVmV5ekNiYW5FCkI3RWUvVTdVZ2dJeWVSY085SW5G
amhNci9tRDl2Qlp4dlBGZFYzTGxYdm8KLS0tIDZZWTYxQmVqOEZQaTNOODFGWUhn UWNOempRQlFCUUlmL0RIR1ZZZVI5cDQKLS0tIEpwZkVodENLM1VnQ1o1TmhRSFFi
cXpJL3poT3dpYjZKWTN6dGpOV3kxT2sKozsOz+cSYJdZ0C2L6QCf/VSU9DnOz6ae ZEF5MUNVdHRKcW9aK0M2M0ZVNDBxbjgKw8Wp06PcoStrO6ppsOR5zWjXbYrP64Dv
lqV5MMzSl1Jf8ETpqt+PhvvWz+MLCAkIriT9yf6R29DQifCacB7XOA== XAEHQMa7vyvuu12YIa/fpyDM4HrsrPq3OWudxuWS6p0X8xmPYrXm6g==
-----END AGE ENCRYPTED FILE----- -----END AGE ENCRYPTED FILE-----
- recipient: age1plkp8td6zzfcavjusmsfrlk54t9vn8jjxm8zaz7cmnr7kzl2nfnsd54hwg - recipient: age1plkp8td6zzfcavjusmsfrlk54t9vn8jjxm8zaz7cmnr7kzl2nfnsd54hwg
enc: | enc: |
-----BEGIN AGE ENCRYPTED FILE----- -----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBySkpOYWxjQVZRbGtkNXlt YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSB1OU9mZHZFanpTQjE3aGly
OTRKTDlrNjNMenU3V0hPeXYyRnhGVU1mMmhNCmhJTi9ZQzB3ekpSR0k1VDFiNExu VVZDWFh5ZmRHV1YwWUtsZVd6R090SnkxVVNVCmM1UWwwaFlqTHlhSGFRblZBbVhl
dW9TQkI3Vy9LOXhQaEExZHMyM25xZlEKLS0tIGRYTkpzbjIvL1FMS2lYYXl4dDVZ aEhtWWp1N1lVTDNqOFJBL0swVFl3Y3cKLS0tIG5ZTHAveFdLdGhQeUc2Tzh1d0dw
U040akh0Z1ZYVmdjS3k2ZjFRK2VRNGMKqSCnviWARWTkZXeht+sdOYKAxylYYyZK UzRhNGNTZFR3U3BLNFhCdFp6aU5uZVEKL4K5jFjPfMp/fMA8+nQerj6PE5zvGeHW
uXYE3nBaXGosIqmTf6deVqCIY+m0mH/J4UMcbH+faMV4pWmVr2JAxg== 1SuHwnDiglKmksj8Gy+7spwLQCmo11JCnW2gXKktVIe5XOyhortq3Q==
-----END AGE ENCRYPTED FILE----- -----END AGE ENCRYPTED FILE-----
- recipient: age12g6f5fse25tgrwweleh4jls3qs52hey2edh759smulwmk5lnzadslu2cp3 - recipient: age12g6f5fse25tgrwweleh4jls3qs52hey2edh759smulwmk5lnzadslu2cp3
enc: | enc: |
-----BEGIN AGE ENCRYPTED FILE----- -----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBJbUhzZStoUVBHUkZLWlE3 YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSB4Z1lXM0tDRzJnMytDZHBV
NWNiRkJMdXhUVXRNZTFCMUljVzIxY3BVMFQ4ClpmOGhqeUZiaG1HcU5zdndmWE5y ZlpCcTg0U3RGeEVzN2dBMkFYa0FBMFVKMWw4Cm5KeWMvdFBRNHFJWFNSS3NVWlNQ
Ym5OTmoyVVVsb2Ywa3loRTVNZzdlVjQKLS0tIHNEWVV3Mkk2VGVzR3diQW5Ccm1a dnhkc2F2MFVTMmVHT2U2STdUWWcvNzAKLS0tIHkzQXltQW5TWHVrektQU1hES1ll
MVNUYjZCME9rQWFUaWNycEh5THQyTTAKTBnoF76mJ/GoCIq4TsmV+luYbiWnx0+I UnVld1laTWRmTlkzWHRKRjJmWVJhbU0KANxoSnhDbxsja+Eo8MVCGv6iThNmlo/m
BEISvqsr9gbT0z8kfdo/htPoKHZmnyevZhRhd2AMZdKixYvQMX9sjA== y0RXVNVqNlqTreT0F/SKmP74W3lF30nwsfrOywMQyu75k8p5MGwUuQ==
-----END AGE ENCRYPTED FILE----- -----END AGE ENCRYPTED FILE-----
- recipient: age1tl80n23wq6zxegupwn70ew0yp225ua5v4dk800x7g2w6pvlxz46qk592pa - recipient: age1tl80n23wq6zxegupwn70ew0yp225ua5v4dk800x7g2w6pvlxz46qk592pa
enc: | enc: |
-----BEGIN AGE ENCRYPTED FILE----- -----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBDWEZkODBNOGw2WFdncjJ0 YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBwUm9oT3RYbGZXaW5CM20r
TTVzRHlEa1AzaTF4V2hYR3hFRGg2cnBzYmowCmp3WDJ3bEZoTlFYL2hoZ3hhTVU1 QlYyeGZjMG05MFNzdndCU2dUemZqdXF5Um1FCkZWdVlubW1MZ3NwM3V2S3dQRFA1
WnQyYk03K2xmSk00dS92OHNNZnRIL2cKLS0tIEVrbjY4enJBZzdQMjRCRmwwVlRI cHVvOXBMMGtMdkF4T2s3Wk9wYkJUZUEKLS0tIFBRM3FWYTRrVDNwZStwQWlpbEYx
OHVOMm9NTGdJbnZ2aXYxdi9OdWpkVE0K4b1Hu6rOHVtfH601aXb/uTGYjNMh6yW/ dXA5WE5udnhhMVdLTk1jMzJuU3VWaTQKaxTpyqi5fjmOFR4qOxm+wSqWDxb/96QI
LetO+HKk+VEzXHntObK2k/4mTl5I0+OP5H8+PR0jdIUZDpr79iEbgQ== rHGUI+CqMVKZ6w1fMH0uanvCuw7Q9rbmsKB+DsjARMLFGqxzP/psUA==
-----END AGE ENCRYPTED FILE----- -----END AGE ENCRYPTED FILE-----
- recipient: age1qy04neuzwpasmvljqrcvhwnf0kz5cpyteze38c8avp0czewskasszv9pyw - recipient: age1qy04neuzwpasmvljqrcvhwnf0kz5cpyteze38c8avp0czewskasszv9pyw
enc: | enc: |
-----BEGIN AGE ENCRYPTED FILE----- -----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBqVE9iMmUwTXE2SHZNdG5P YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBwMmVOaHV1Unl4UmUrVXEy
Vi9XQ1Jkc1VDamFlakpkZk45ODZ2YnkwYkVBCnNrbktIdkV4UGltcHBUUHlXbjdx REJIYWZhQmt3R0R5RFRveTl4Z0JaZlpKNmd3Ckl0dkFUaG90TE5odVh0YVprMVZj
Z0QwM3ZKbGI1cDBjL2g2cjdKdElOQjAKLS0tIGxrcTJDa1BWVWcxUS80MmxIMWZH T2tXS0dSRGVVczdhb1dlVlV0L3JZUncKLS0tIGo2RDBPZXNzU2dQQlJhY0NBS2ps
YjBRMDZJZWlmN1FNaXV5c04yVWtleE0K+nGNyFzqSotFP7My/kUnAgxXGu/ji50K RW92OFVTS3d2L3UxOXNrVUFSVFI5TXMKn3pdHbXxBccG6Q+gWPVQK/5wiIKkzdhm
OGVLYgNvU48rCGck3r9ZrKY1HpQdAY8UMQXECsuO4HgdirNjiZ97Zg== HiNzezStrkFHf/lsFS2LNgYkfMMzBQ4rJj7oQkrD1Z8j6qRld3zzLg==
-----END AGE ENCRYPTED FILE----- -----END AGE ENCRYPTED FILE-----
- recipient: age18fgn6j2vwwswqcpv9xpcehq8mrf9zs2sglwkamp3tzwx8d9jq9jsrskrk9 - recipient: age18fgn6j2vwwswqcpv9xpcehq8mrf9zs2sglwkamp3tzwx8d9jq9jsrskrk9
enc: | enc: |
-----BEGIN AGE ENCRYPTED FILE----- -----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSA4ZXZud0dqb0dkQ0E3NnE4 YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSAzeWN4ZE5Ya3RPV2VKRFdv
SXB0ZENjQk1mb1BHU2R1bW0waDhTYy9OZldVCjVnRTV5d1c3Q2NzcEVRQ3BoL09I UGQ4QTJUZFovSXpQREhNWXdQbktVUHFQaUhjCmM1dXNxbGdlVjBWNjc5L2E5QlBy
T1RPQ3hHT3Y2NFNzWG9EdGM2STR2STgKLS0tIHBvL3RhREFNTVdwUGk3S1B4NWJL NjI0S251cjZvYU1nQ2swWXVDdjdhSG8KLS0tIHdFNXptN1VBNytFNW40MUsxTGFM
TnZpblF1SDdGRlVXM0dEdFAzT1FEMUUK6L8gTv5gt6++A3B7PHyWl+xtBUc8bC6G WlVnc3VLY1ZReDFxUVlqWU9rM0xMQ00Kq/ckdP1N4BDBo0pSH9pp36skIacwNm3d
53xoJvyyBpaov3HgUAdrN9VHubfEJmrBGgN7DngGgwYPtlhV87M7/w== utiuCgG16Hqe6YDb3agx81BocuSJ9oMOdpegoztnkkBDDP0F+e/VuQ==
-----END AGE ENCRYPTED FILE----- -----END AGE ENCRYPTED FILE-----
- recipient: age1hm2hsfgjezpsc3k0y5w5feq9t8vl3seq04qjhgt6ztd6403wfvpsgxu09m lastmodified: "2024-07-18T13:08:42Z"
enc: | mac: ENC[AES256_GCM,data:a/uHyw9V/SMIePV9nPf+wJgPg+YDYLJGYy7NMLBrBgCXtBWHHonSNjzdmtjix1bW2y+cU0gMqodrtqR1cJGBmXr4NRY7NJqgLWE9rEdYfG7BnfqsWmvAaTIrSs7QMZWkEic7ys/bXoA5BZoau3olhVqIO2A/iyBtoMU9Hv7hPlo=,iv:gaqSCUbN7cxWPNrFPDTl7xNxpOZL6GY/swD/MDCiRqk=,tag:Oz0f/DyD3KGV/9Rprj/1Xw==,type:str]
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBlaXY1VmtDejcwTmUxRVZT
YURhMkVPaHNvb0sxT0FYL0pvN3hqclNNcXdRCmxWV3FGeDZTM1VVMVRyalpkVnFJ
OGU3Wk9wVVAvejVTdjc1MENPcy9Qc1kKLS0tIGpJQXhZVzV3REc2SFlFSXg0dUo5
bjRBaGtJdUFmVUkxeGgwbGYwWjRnNEkKYwzwZ9oOo+C6XD57rkUTO6QADZKzYfSF
cFJ7fX0NyZbzxLncyofWa+dlLWLZ3KohIP0doAFngRm+RVsUEVqY5A==
-----END AGE ENCRYPTED FILE-----
- recipient: age1hnhuzj96ktkhpyygvmz0x9h8mfvssz7ss6emmukags644mdhf4msajk93r
enc: |
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSB1aFNsL2xvWmI4UTAxREc4
NFF3bC9qRTBqS3JrM3B0ZjE5bEtjR0diT0VjClhFNStFU3RydnhvcG9CSmhYM3V4
VjZ5c0JQZjRoQXh1R2UyeDMyd2NFMEEKLS0tIDNwWUNzZmlrNGZPbERTeFpoUkxO
QnZTWWFMemk5djVNWFRaekVMRkMyUjgKt4dw4BOm3J1Ig6U58NbSjzJbWi3ak/Zq
8PX5IW7tq1q5+Qd3adqv3cd9S2aVpqjHyN34fxagmuwfvYXVyQ2GDg==
-----END AGE ENCRYPTED FILE-----
lastmodified: "2024-10-02T07:30:09Z"
mac: ENC[AES256_GCM,data:BdEiR/7AiTz9eppAGOAarFzUJYEfCZzb0lg8LXaHiXe74B5Ob7Ai+XuBBX+x9QPIFzbLZgVveVSrqymW0wAH9Dv5R+e4spDf5KKdRCr9RADfCXNjYC0N9grZVerM70Ic51Lc1kKDnB2mon01W5Sa77Ei29Jo988yvM8AOlXFvr4=,iv:p7PCazxKNv7YcGX7Kpp2L8wXEFaJO8FajEXcVMzmmWQ=,tag:WJKZOkFZSof6IhcXqc60uQ==,type:str]
pgp: [] pgp: []
unencrypted_suffix: _unencrypted unencrypted_suffix: _unencrypted
version: 3.9.0 version: 3.8.1
@@ -49,8 +49,6 @@ backend:
LIVEKIT_API_URL: https://livekit.127.0.0.1.nip.io/ LIVEKIT_API_URL: https://livekit.127.0.0.1.nip.io/
ANALYTICS_KEY: xwhoIMCZ8PBRjQ2t ANALYTICS_KEY: xwhoIMCZ8PBRjQ2t
ALLOW_UNREGISTERED_ROOMS: False ALLOW_UNREGISTERED_ROOMS: False
FRONTEND_SILENCE_LIVEKIT_DEBUG: False
FRONTEND_SUPPORT: "{'id': '58ea6697-8eba-4492-bc59-ad6562585041'}"
migrate: migrate:
@@ -97,11 +95,3 @@ ingress:
ingressAdmin: ingressAdmin:
enabled: true enabled: true
host: meet.127.0.0.1.nip.io host: meet.127.0.0.1.nip.io
posthog:
ingress:
enabled: false
ingressAssets:
enabled: false
@@ -0,0 +1,127 @@
image:
repository: lasuite/meet-backend
pullPolicy: Always
tag: "v0.1.5"
backend:
migrateJobAnnotations:
argocd.argoproj.io/hook: PreSync
argocd.argoproj.io/hook-delete-policy: HookSucceeded
envVars:
DJANGO_CSRF_TRUSTED_ORIGINS: http://meet-preprod.beta.numerique.gouv.fr,https://meet-preprod.beta.numerique.gouv.fr
DJANGO_CONFIGURATION: Production
DJANGO_ALLOWED_HOSTS: meet-preprod.beta.numerique.gouv.fr
DJANGO_SUPERUSER_EMAIL:
secretKeyRef:
name: backend
key: DJANGO_SUPERUSER_EMAIL
DJANGO_SECRET_KEY:
secretKeyRef:
name: backend
key: DJANGO_SECRET_KEY
DJANGO_SETTINGS_MODULE: meet.settings
DJANGO_SILENCED_SYSTEM_CHECKS: security.W004, security.W008
DJANGO_SUPERUSER_PASSWORD:
secretKeyRef:
name: backend
key: DJANGO_SUPERUSER_PASSWORD
DJANGO_EMAIL_HOST: "snap-mail.numerique.gouv.fr"
DJANGO_EMAIL_PORT: 465
DJANGO_EMAIL_USE_SSL: True
OIDC_OP_JWKS_ENDPOINT: https://fca.integ01.dev-agentconnect.fr/api/v2/jwks
OIDC_OP_AUTHORIZATION_ENDPOINT: https://fca.integ01.dev-agentconnect.fr/api/v2/authorize
OIDC_OP_TOKEN_ENDPOINT: https://fca.integ01.dev-agentconnect.fr/api/v2/token
OIDC_OP_USER_ENDPOINT: https://fca.integ01.dev-agentconnect.fr/api/v2/userinfo
OIDC_OP_LOGOUT_ENDPOINT: https://fca.integ01.dev-agentconnect.fr/api/v2/session/end
OIDC_RP_CLIENT_ID:
secretKeyRef:
name: backend
key: OIDC_RP_CLIENT_ID
OIDC_RP_CLIENT_SECRET:
secretKeyRef:
name: backend
key: OIDC_RP_CLIENT_SECRET
OIDC_RP_SIGN_ALGO: RS256
OIDC_RP_SCOPES: "openid email"
OIDC_REDIRECT_ALLOWED_HOSTS: https://meet-preprod.beta.numerique.gouv.fr
OIDC_AUTH_REQUEST_EXTRA_PARAMS: "{'acr_values': 'eidas1'}"
LOGIN_REDIRECT_URL: https://meet-preprod.beta.numerique.gouv.fr
LOGIN_REDIRECT_URL_FAILURE: https://meet-preprod.beta.numerique.gouv.fr
LOGOUT_REDIRECT_URL: https://meet-preprod.beta.numerique.gouv.fr
DB_HOST:
secretKeyRef:
name: postgresql.postgres.libre.sh
key: host
DB_NAME:
secretKeyRef:
name: postgresql.postgres.libre.sh
key: database
DB_USER:
secretKeyRef:
name: postgresql.postgres.libre.sh
key: username
DB_PASSWORD:
secretKeyRef:
name: postgresql.postgres.libre.sh
key: password
DB_PORT:
secretKeyRef:
name: postgresql.postgres.libre.sh
key: port
POSTGRES_USER:
secretKeyRef:
name: postgresql.postgres.libre.sh
key: username
POSTGRES_DB:
secretKeyRef:
name: postgresql.postgres.libre.sh
key: database
POSTGRES_PASSWORD:
secretKeyRef:
name: postgresql.postgres.libre.sh
key: password
REDIS_URL:
secretKeyRef:
name: redis.redis.libre.sh
key: url
STORAGES_STATICFILES_BACKEND: django.contrib.staticfiles.storage.StaticFilesStorage
LIVEKIT_API_SECRET:
secretKeyRef:
name: backend
key: LIVEKIT_API_SECRET
LIVEKIT_API_KEY:
secretKeyRef:
name: backend
key: LIVEKIT_API_KEY
LIVEKIT_API_URL: https://livekit-preprod.beta.numerique.gouv.fr
ALLOW_UNREGISTERED_ROOMS: False
createsuperuser:
command:
- "/bin/sh"
- "-c"
- |
python manage.py createsuperuser --email $DJANGO_SUPERUSER_EMAIL --password $DJANGO_SUPERUSER_PASSWORD
restartPolicy: Never
frontend:
image:
repository: lasuite/meet-frontend
pullPolicy: Always
tag: "v0.1.5"
ingress:
enabled: true
host: meet-preprod.beta.numerique.gouv.fr
className: nginx
annotations:
cert-manager.io/cluster-issuer: letsencrypt-prod
ingressAdmin:
enabled: true
host: meet-preprod.beta.numerique.gouv.fr
className: nginx
annotations:
cert-manager.io/cluster-issuer: letsencrypt-prod
nginx.ingress.kubernetes.io/auth-signin: https://oauth2-proxy-preprod.beta.numerique.gouv.fr/oauth2/start
nginx.ingress.kubernetes.io/auth-url: https://oauth2-proxy-preprod.beta.numerique.gouv.fr/oauth2/auth
@@ -1,16 +1,16 @@
image: image:
repository: lasuite/meet-backend repository: lasuite/meet-backend
pullPolicy: Always pullPolicy: Always
tag: "v0.1.7" tag: "v0.1.5"
backend: backend:
migrateJobAnnotations: migrateJobAnnotations:
argocd.argoproj.io/hook: PostSync argocd.argoproj.io/hook: PostSync
argocd.argoproj.io/hook-delete-policy: HookSucceeded argocd.argoproj.io/hook-delete-policy: HookSucceeded
envVars: envVars:
DJANGO_CSRF_TRUSTED_ORIGINS: https://visio.numerique.gouv.fr,https://meet.numerique.gouv.fr DJANGO_CSRF_TRUSTED_ORIGINS: https://meet.numerique.gouv.fr
DJANGO_CONFIGURATION: Production DJANGO_CONFIGURATION: Production
DJANGO_ALLOWED_HOSTS: visio.numerique.gouv.fr,meet.numerique.gouv.fr DJANGO_ALLOWED_HOSTS: meet.numerique.gouv.fr
DJANGO_SECRET_KEY: DJANGO_SECRET_KEY:
secretKeyRef: secretKeyRef:
name: backend name: backend
@@ -43,11 +43,11 @@ backend:
key: OIDC_RP_CLIENT_SECRET key: OIDC_RP_CLIENT_SECRET
OIDC_RP_SIGN_ALGO: RS256 OIDC_RP_SIGN_ALGO: RS256
OIDC_RP_SCOPES: "openid email" OIDC_RP_SCOPES: "openid email"
OIDC_REDIRECT_ALLOWED_HOSTS: https://visio.numerique.gouv.fr OIDC_REDIRECT_ALLOWED_HOSTS: https://meet.numerique.gouv.fr
OIDC_AUTH_REQUEST_EXTRA_PARAMS: "{'acr_values': 'eidas1'}" OIDC_AUTH_REQUEST_EXTRA_PARAMS: "{'acr_values': 'eidas1'}"
LOGIN_REDIRECT_URL: https://visio.numerique.gouv.fr LOGIN_REDIRECT_URL: https://meet.numerique.gouv.fr
LOGIN_REDIRECT_URL_FAILURE: https://visio.numerique.gouv.fr LOGIN_REDIRECT_URL_FAILURE: https://meet.numerique.gouv.fr
LOGOUT_REDIRECT_URL: https://visio.numerique.gouv.fr LOGOUT_REDIRECT_URL: https://meet.numerique.gouv.fr
DB_HOST: DB_HOST:
secretKeyRef: secretKeyRef:
name: postgresql.postgres.libre.sh name: postgresql.postgres.libre.sh
@@ -96,26 +96,6 @@ backend:
LIVEKIT_API_URL: https://livekit-preprod.beta.numerique.gouv.fr LIVEKIT_API_URL: https://livekit-preprod.beta.numerique.gouv.fr
ANALYTICS_KEY: mwuxTKi8o2xzWH54 ANALYTICS_KEY: mwuxTKi8o2xzWH54
ALLOW_UNREGISTERED_ROOMS: False ALLOW_UNREGISTERED_ROOMS: False
FRONTEND_SILENCE_LIVEKIT_DEBUG: False
FRONTEND_ANALYTICS: "{'id': 'phc_RPYko028Oqtj0c9exLIWwrlrjLxSdxT0ntW0Lam4iom', 'host': 'https://product.visio.numerique.gouv.fr'}"
FRONTEND_SUPPORT: "{'id': '58ea6697-8eba-4492-bc59-ad6562585041'}"
AWS_S3_ENDPOINT_URL:
secretKeyRef:
name: impress-media-storage.bucket.libre.sh
key: url
AWS_S3_ACCESS_KEY_ID:
secretKeyRef:
name: impress-media-storage.bucket.libre.sh
key: accessKey
AWS_S3_SECRET_ACCESS_KEY:
secretKeyRef:
name: impress-media-storage.bucket.libre.sh
key: secretKey
AWS_STORAGE_BUCKET_NAME:
secretKeyRef:
name: impress-media-storage.bucket.libre.sh
key: bucket
AWS_S3_REGION_NAME: local
createsuperuser: createsuperuser:
command: command:
@@ -129,39 +109,20 @@ frontend:
image: image:
repository: lasuite/meet-frontend repository: lasuite/meet-frontend
pullPolicy: Always pullPolicy: Always
tag: "v0.1.7" tag: "v0.1.5"
ingress: ingress:
enabled: true enabled: true
host: visio.numerique.gouv.fr host: meet.numerique.gouv.fr
className: nginx className: nginx
annotations: annotations:
cert-manager.io/cluster-issuer: letsencrypt cert-manager.io/cluster-issuer: letsencrypt
ingressAdmin: ingressAdmin:
enabled: true enabled: true
host: visio.numerique.gouv.fr host: meet.numerique.gouv.fr
className: nginx className: nginx
annotations: annotations:
cert-manager.io/cluster-issuer: letsencrypt cert-manager.io/cluster-issuer: letsencrypt
nginx.ingress.kubernetes.io/auth-signin: https://oauth2-proxy.beta.numerique.gouv.fr/oauth2/start nginx.ingress.kubernetes.io/auth-signin: https://oauth2-proxy.beta.numerique.gouv.fr/oauth2/start
nginx.ingress.kubernetes.io/auth-url: https://oauth2-proxy.beta.numerique.gouv.fr/oauth2/auth nginx.ingress.kubernetes.io/auth-url: https://oauth2-proxy.beta.numerique.gouv.fr/oauth2/auth
posthog:
ingress:
enabled: true
host: product.visio.numerique.gouv.fr
className: nginx
annotations:
cert-manager.io/cluster-issuer: letsencrypt
nginx.ingress.kubernetes.io/upstream-vhost: eu.i.posthog.com
nginx.ingress.kubernetes.io/backend-protocol: https
ingressAssets:
enabled: true
host: product.visio.numerique.gouv.fr
className: nginx
annotations:
cert-manager.io/cluster-issuer: letsencrypt
nginx.ingress.kubernetes.io/upstream-vhost: eu-assets.i.posthog.com
nginx.ingress.kubernetes.io/backend-protocol: https
+10 -78
View File
@@ -1,16 +1,16 @@
image: image:
repository: lasuite/meet-backend repository: lasuite/meet-backend
pullPolicy: Always pullPolicy: Always
tag: "v-hackathon" tag: "hackathon"
backend: backend:
migrateJobAnnotations: migrateJobAnnotations:
argocd.argoproj.io/hook: PreSync argocd.argoproj.io/hook: PreSync
argocd.argoproj.io/hook-delete-policy: HookSucceeded argocd.argoproj.io/hook-delete-policy: HookSucceeded
envVars: envVars:
DJANGO_CSRF_TRUSTED_ORIGINS: http://visio-staging.beta.numerique.gouv.fr,https://meet-staging.beta.numerique.gouv.fr DJANGO_CSRF_TRUSTED_ORIGINS: http://meet-staging.beta.numerique.gouv.fr,https://meet-staging.beta.numerique.gouv.fr
DJANGO_CONFIGURATION: Production DJANGO_CONFIGURATION: Production
DJANGO_ALLOWED_HOSTS: visio-staging.beta.numerique.gouv.fr DJANGO_ALLOWED_HOSTS: meet-staging.beta.numerique.gouv.fr
DJANGO_SECRET_KEY: DJANGO_SECRET_KEY:
secretKeyRef: secretKeyRef:
name: backend name: backend
@@ -42,11 +42,11 @@ backend:
key: OIDC_RP_CLIENT_SECRET key: OIDC_RP_CLIENT_SECRET
OIDC_RP_SIGN_ALGO: RS256 OIDC_RP_SIGN_ALGO: RS256
OIDC_RP_SCOPES: "openid email" OIDC_RP_SCOPES: "openid email"
OIDC_REDIRECT_ALLOWED_HOSTS: https://visio-staging.beta.numerique.gouv.fr OIDC_REDIRECT_ALLOWED_HOSTS: https://meet-staging.beta.numerique.gouv.fr
OIDC_AUTH_REQUEST_EXTRA_PARAMS: "{'acr_values': 'eidas1'}" OIDC_AUTH_REQUEST_EXTRA_PARAMS: "{'acr_values': 'eidas1'}"
LOGIN_REDIRECT_URL: https://visio-staging.beta.numerique.gouv.fr LOGIN_REDIRECT_URL: https://meet-staging.beta.numerique.gouv.fr
LOGIN_REDIRECT_URL_FAILURE: https://visio-staging.beta.numerique.gouv.fr LOGIN_REDIRECT_URL_FAILURE: https://meet-staging.beta.numerique.gouv.fr
LOGOUT_REDIRECT_URL: https://visio-staging.beta.numerique.gouv.fr LOGOUT_REDIRECT_URL: https://meet-staging.beta.numerique.gouv.fr
DB_HOST: DB_HOST:
secretKeyRef: secretKeyRef:
name: postgresql.postgres.libre.sh name: postgresql.postgres.libre.sh
@@ -95,43 +95,6 @@ backend:
LIVEKIT_API_URL: https://livekit-staging.beta.numerique.gouv.fr LIVEKIT_API_URL: https://livekit-staging.beta.numerique.gouv.fr
ANALYTICS_KEY: Roi1k6IAc2DEqHB0 ANALYTICS_KEY: Roi1k6IAc2DEqHB0
ALLOW_UNREGISTERED_ROOMS: False ALLOW_UNREGISTERED_ROOMS: False
FRONTEND_ANALYTICS: "{'id': 'phc_RPYko028Oqtj0c9exLIWwrlrjLxSdxT0ntW0Lam4iom', 'host': 'https://product.visio-staging.beta.numerique.gouv.fr'}"
FRONTEND_SUPPORT: "{'id': '58ea6697-8eba-4492-bc59-ad6562585041'}"
AWS_S3_ENDPOINT_URL:
secretKeyRef:
name: meet-media-storage.bucket.libre.sh
key: url
AWS_S3_ACCESS_KEY_ID:
secretKeyRef:
name: meet-media-storage.bucket.libre.sh
key: accessKey
AWS_S3_SECRET_ACCESS_KEY:
secretKeyRef:
name: meet-media-storage.bucket.libre.sh
key: secretKey
AWS_STORAGE_BUCKET_NAME:
secretKeyRef:
name: meet-media-storage.bucket.libre.sh
key: bucket
AWS_S3_REGION_NAME: local
OPENAI_API_KEY:
secretKeyRef:
name: backend
key: OPENAI_API_KEY
OPENAI_ENABLE: True
MINIO_ACCESS_KEY:
secretKeyRef:
name: backend
key: MINIO_ACCESS_KEY
MINIO_SECRET_KEY:
secretKeyRef:
name: backend
key: MINIO_SECRET_KEY
MINIO_URL:
secretKeyRef:
name: backend
key: MINIO_URL
createsuperuser: createsuperuser:
command: command:
@@ -145,51 +108,20 @@ frontend:
image: image:
repository: lasuite/meet-frontend repository: lasuite/meet-frontend
pullPolicy: Always pullPolicy: Always
tag: "v-hackathon" tag: "hackathon"
ingress: ingress:
enabled: true enabled: true
host: visio-staging.beta.numerique.gouv.fr host: meet-staging.beta.numerique.gouv.fr
className: nginx className: nginx
annotations: annotations:
cert-manager.io/cluster-issuer: letsencrypt-prod cert-manager.io/cluster-issuer: letsencrypt-prod
tls:
enabled: true
additional:
- secretName: transitional-tls
hosts:
- {{ .Values.newDomain }}
ingressAdmin: ingressAdmin:
enabled: true enabled: true
host: visio-staging.beta.numerique.gouv.fr host: meet-staging.beta.numerique.gouv.fr
className: nginx className: nginx
annotations: annotations:
cert-manager.io/cluster-issuer: letsencrypt-prod cert-manager.io/cluster-issuer: letsencrypt-prod
nginx.ingress.kubernetes.io/auth-signin: https://oauth2-proxy-preprod.beta.numerique.gouv.fr/oauth2/start nginx.ingress.kubernetes.io/auth-signin: https://oauth2-proxy-preprod.beta.numerique.gouv.fr/oauth2/start
nginx.ingress.kubernetes.io/auth-url: https://oauth2-proxy-preprod.beta.numerique.gouv.fr/oauth2/auth nginx.ingress.kubernetes.io/auth-url: https://oauth2-proxy-preprod.beta.numerique.gouv.fr/oauth2/auth
tls:
enabled: true
additional:
- secretName: transitional-tls
hosts:
- {{ .Values.newDomain }}
posthog:
ingress:
enabled: true
host: product.visio-staging.beta.numerique.gouv.fr
className: nginx
annotations:
cert-manager.io/cluster-issuer: letsencrypt-prod
nginx.ingress.kubernetes.io/upstream-vhost: eu.i.posthog.com
nginx.ingress.kubernetes.io/backend-protocol: https
ingressAssets:
enabled: true
host: product.visio-staging.beta.numerique.gouv.fr
className: nginx
annotations:
cert-manager.io/cluster-issuer: letsencrypt-prod
nginx.ingress.kubernetes.io/upstream-vhost: eu-assets.i.posthog.com
nginx.ingress.kubernetes.io/backend-protocol: https
-55
View File
@@ -1,55 +0,0 @@
{{ if .Values.addRedirect }}
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
annotations:
cert-manager.io/cluster-issuer: letsencrypt-prod
{{ if .Values.enablePermanentRedirect }}
nginx.ingress.kubernetes.io/permanent-redirect: "https://{{ .Values.newDomain }}$request_uri"
nginx.ingress.kubernetes.io/permanent-redirect-code: "308"
{{ end }}
name: temporary-redirect
namespace: {{ .Release.Namespace | quote }}
spec:
ingressClassName: nginx
rules:
- host: {{ .Values.oldDomain }}
http:
paths:
- backend:
service:
name: meet-frontend
port:
number: 80
path: /
pathType: Prefix
- backend:
service:
name: meet-backend
port:
number: 80
path: /api
pathType: Prefix
tls:
- hosts:
- {{ .Values.oldDomain }}
secretName: transitional-tls
---
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: transitional-tls
namespace: {{ .Release.Namespace | quote }}
spec:
dnsNames:
- {{ .Values.newDomain }}
- {{ .Values.oldDomain }}
issuerRef:
group: cert-manager.io
kind: ClusterIssuer
name: {{ index .Values.ingress.annotations "cert-manager.io/cluster-issuer" }}
secretName: transitional-tls
usages:
- digital signature
- key encipherment
{{ end }}

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