Compare commits

...

66 Commits

Author SHA1 Message Date
lebaudantoine 808fc7a00f wip update prompt 2024-10-18 10:00:38 +02:00
lebaudantoine c8b4592d88 wip iterate on the prompt 2024-10-17 16:54:09 +02:00
lebaudantoine db071f0dcc wip iterate on the prompt 2024-10-17 14:04:38 +02:00
lebaudantoine b1b28fe330 iterate on prompt 2024-10-17 13:40:31 +02:00
lebaudantoine 98950f43af fix logger 2024-10-17 12:08:37 +02:00
lebaudantoine b43f9922b6 wip enable openai 2024-10-17 11:56:47 +02:00
lebaudantoine d49dc72849 fix endpoint 2024-10-17 11:55:08 +02:00
lebaudantoine 82f0251101 fix code 2024-10-17 11:15:25 +02:00
lebaudantoine 2f675c2a3f wip clean and get blocknote content 2024-10-17 10:55:05 +02:00
lebaudantoine 7105c7cbae remove tl 2024-10-16 18:04:44 +02:00
lebaudantoine 376ff8982c wip room_slug 2024-10-16 17:57:58 +02:00
lebaudantoine a4820ae867 wip email user 2024-10-16 17:47:32 +02:00
lebaudantoine 2a56cda55c wip enable openai 2024-10-16 15:07:27 +02:00
Jacques ROUSSEL 817cf25b37 wip bump secret 2024-10-16 14:57:15 +02:00
lebaudantoine fdaf567f5d wip revert minio 2024-10-16 14:41:41 +02:00
lebaudantoine 0cf5ab1825 wip 2024-10-16 13:40:12 +02:00
lebaudantoine c9c5d3b452 wip debug bucket 2024-10-16 13:26:47 +02:00
lebaudantoine f0b250739c wip get room from filename 2024-10-16 12:16:08 +02:00
lebaudantoine 41c9693d10 wip handle if file doesn't exist 2024-10-16 12:14:56 +02:00
lebaudantoine 83cfcacc0e wip add prompt and summary 2024-10-16 12:14:39 +02:00
lebaudantoine e8618099ac wip add some logs and allow toggling openai request 2024-10-16 12:00:07 +02:00
Jacques ROUSSEL ac183c9eb9 wip bump secrets 2024-10-16 10:47:50 +02:00
lebaudantoine 3dcc93b630 wip feedbacks 2024-10-16 10:34:17 +02:00
lebaudantoine 3614b1e803 wip create openai client 2024-10-16 10:23:01 +02:00
lebaudantoine 5e57647b34 wip connect minio 2024-10-16 10:22:41 +02:00
lebaudantoine e271c87a20 wip draft a mini webhook 2024-10-16 10:07:58 +02:00
lebaudantoine 40d1f01277 wip deploy to staging 2024-10-16 10:03:21 +02:00
lebaudantoine 8477296471 wip add recording indicator 2024-10-15 21:54:18 +02:00
lebaudantoine d200eeb6bd wip grant recording permission 2024-10-15 21:31:40 +02:00
lebaudantoine 7a51b09664 wip allow starting a recording from the frontend 2024-10-15 21:29:54 +02:00
lebaudantoine ec22abf82b 💄(frontend) adjust few minor details
Temporary styles, will be fixed when redesigning the UI.
Inspired by GMeet.
2024-10-15 09:27:02 +02:00
lebaudantoine cde4b10794 ♻️(frontend) update icon for feedback option
This new names feels clearer that 'feedback' is an option for users
to express themselves.
2024-10-15 09:27:02 +02:00
lebaudantoine a47f1f92c4 ♻️(frontend) enhance useSidePanel
Encapsulate all interactions with the layout store concerning the
side panel into a hook. This hook exposes a clear interface.
Each variable has a single responsability, with a clear naming.
2024-10-15 09:27:02 +02:00
lebaudantoine 0db36c788b ♻️(frontend) rename useWidget to useSidePanel
Rename useWidget interaction to useSidePanel. Remove LiveKit-specific naming
as we no longer use LiveKit elements in the layout context. This change
improves clarity and reflects the current functionality of the hook.
2024-10-15 09:27:02 +02:00
lebaudantoine a84b76170d 💩(frontend) integrate chat into sidepanel and revamp UI
Properly integrate chat into the sidepanel to improve UX and avoid disruptions.
Implement initial styling based on Google Meet's design, with plans for future
enhancements. Some details remain to be refined, such as preserving newline
characters in the message formatter.

This substantial commit refactors and cleans up a significant legacy component.
Chat notifications will be addressed in a separate PR.

Note: While this is a large commit, it represents a major improvement in user
experience (in my opinion).
2024-10-15 09:27:02 +02:00
lebaudantoine 998382020d ♻️(frontend) refactor backported LiveKit sources
Backported changes to LiveKit sources, eliminating the need to listen for
specific Participant events.
2024-10-15 09:27:02 +02:00
lebaudantoine 2a12715673 🚸(frontend) enhance side panel UX
Implement smooth animations and DOM persistence for sidepanel.
Side panel state should be kept alive, to match initial LiveKit team
intent.

Temporarily, remove chat component. Chat functionality will be absent
until next commits. It will be reintegrated in the side panel.
2024-10-15 09:27:02 +02:00
lebaudantoine 54d4330a97 🩹(frontend) clean up notifications when participant quits
When a participant leaves a videoconference, remove all notifications they
authored. It's generally unnecessary to keep notifications about actions from
users who are no longer present, with few exceptions.

As we currently support only two notification types (Joined and Raised Hand),
it's appropriate to close all of them when the author leaves the room.
2024-10-15 09:27:02 +02:00
lebaudantoine 5dcbe56e56 ♻️(frontend) rename hook useRaisedHand filename
Was using a wrong extension .tsx, renamed it to .ts.
Trivial change.
2024-10-15 09:27:02 +02:00
lebaudantoine 1a52221ef2 ♻️(frontend) remove deprecated chat options
Changes introduced by LiveKit which deprecated some chat's options.
As we duplicated their code, let's just removed them.
They are not useful, and not in use anywhere.
2024-10-15 09:27:02 +02:00
lebaudantoine 2f3e64b389 ♻️(frontend) use newly created chat's input
Refactor and adapt LiveKit original component to use the newly
introduced text area, which is more accessible and
also internationalized.
2024-10-15 09:27:02 +02:00
lebaudantoine 2dcaf814e1 (frontend) introduce chat/input component
Introduce a draft chat input component inspired by Google Meet design.
This component is not yet in use and requires further enhancements.

To be improved:
- Avoid sizing in pixels
- Replace hardcoded colors with theme variables

This lays the groundwork for a more interactive chat experience in the future.
2024-10-15 09:27:02 +02:00
lebaudantoine 583f5b8e70 💄(frontend) add style for disabled icon button
Necessary when the submit message button is disabled.
Bad color management again …
2024-10-15 09:27:02 +02:00
lebaudantoine fe8fd36467 (frontend) introduce a TextArea primitive
Needed for the Chat text message input.
Basic styled RAC text area.
2024-10-15 09:27:02 +02:00
lebaudantoine 0370d9cad0 ♻️(frontend) delegate scroll to side panel content
Allow more flexibility in side panel behavior. Some panels, such as the chat,
require specific scrolling functionality:
- Header and footer should remain fixed
- Only chat messages should be scrollable

This change enables customization of scroll behavior for different panel types,
improving component reusability.
2024-10-15 09:27:02 +02:00
lebaudantoine 0da8aa846a ♻️(frontend) encapsulate side panel values
Refactor values in an enum, enhance code's typing.
2024-10-15 09:27:02 +02:00
lebaudantoine 70ed99b6c9 💩(frontend) duplicate chat prefabs component
Duplicate LiveKit component to start customizing it.
2024-10-15 09:27:02 +02:00
lebaudantoine 1875a394c6 ♻️(frontend) simplify button primitive
In object-oriented terms, the previous implementation violated the Liskov
Substitution Principle. Props between these two components (Button and Link)
were not substitutable.

This led to TypeScript errors and increased overall complexity without
significant DX gains. To address this, the LinkButton has been extracted
into a dedicated component.
2024-10-09 16:40:59 +02:00
renovate[bot] 211ba332dc ⬆️(dependencies) update js dependencies 2024-10-09 16:40:59 +02:00
renovate[bot] 7333c21632 ⬆️(dependencies) update vite to v5.4.6 [SECURITY] 2024-10-09 15:09:59 +02:00
lebaudantoine 78ebd1a8fd 👷(ci) update build push action to v6
Update the build push action.
2024-10-09 14:58:39 +02:00
Jacques ROUSSEL 682b69fc11 🔒️(backend) migrate backend image to alpine
Enhancement made by @rouja while working on the vulnerabilities
found by Trivy scan.
2024-10-09 14:58:39 +02:00
Jacques ROUSSEL 7a73bf8fc2 💚(frontend) fix frontend image vulnerabilities
Fixed vulnerabilities found by the Trivy Scan.
2024-10-09 14:58:39 +02:00
Jacques ROUSSEL 1e934957f5 💚(backend) fix backend image vulnerabilities
Fixed vulnerabilities with setup tools found by the Trivy Scan.
2024-10-09 14:58:39 +02:00
Jacques ROUSSEL 5a7584a3ad 👷(ci) scan for vulnerabilities on Docker images
Configure Trivy Scan in the CI to detect vulnerabilities on our
Docker image. Enhance stack security.
2024-10-09 14:58:39 +02:00
Jacques ROUSSEL fb9bf6b08e 🔐(tilt) add Samuel's key
Add Samuel's key to handle secret.
2024-10-09 11:14:11 +02:00
Jacques ROUSSEL 11b8541005 🔧(backend) fix configuration to avoid different ssl warning
Fix following warning messages :
- You have not set a value for the SECURE_HSTS_SECONDS setting.
- Your SECURE_SSL_REDIRECT setting is not set to True.
2024-09-27 18:46:26 +02:00
lebaudantoine 64607ae8d0 (frontend) introduce push to talk on microphone
I am not a huge fan of changing the component's behavior based on
a if, but that the only way I found to have something quickly.

Actually, the push to talk feature will always only applies to the
microphone. No other devices will be concerned.

Reuse the active speaker indicator to quickly bootstrap the feature.
Some details should be improved in terms of UI. The UX is quite
decent.
2024-09-27 16:34:32 +02:00
lebaudantoine c403bbc347 ♻️(frontend) extract toggle device component
Encapsulate toggle logic in a dedicated component.
Prepare the introduction of the push to talk feature.
2024-09-27 16:34:32 +02:00
lebaudantoine 62855fe12c (frontend) introduce active speaker pushToTalk variant
Will be used to indicate when the push to talk is open.
It gives the user a feedback wether or not her mic is active.
2024-09-27 16:34:32 +02:00
lebaudantoine f27d451eb6 ♻️(frontend) make active speaker keyframe relative to height
Keyframe can now be reused in various context, when the children
size are not fixed to 4px.
2024-09-27 16:34:32 +02:00
lebaudantoine 68792d8019 ✏️(frontend) fix typo in active speaker keyframe
Found a typo and fixed it.
2024-09-27 16:34:32 +02:00
lebaudantoine ebb8c14eeb ♻️(frontend) register shortcut using a hook
Encapsulate the logic for shortcuts registering in a proper hook.
It makes the code reusable and easier to read.
2024-09-27 16:34:32 +02:00
lebaudantoine 15d43b8d5e ♻️(frontend) handle proper shortcut object
Created a type to properly manipulate any data related to a
shortcut. Make the code more concise.
2024-09-27 16:34:32 +02:00
lebaudantoine 1faae96ccd (frontend) introduce long press keyboard shortcut
Needed to support push to talk using the spacebar.
Introduce a utility hook. Will be called by the mic toggle
in the upcoming commits.

Inspired by Jitsi code.
2024-09-27 16:34:32 +02:00
lebaudantoine 651cc0e5bd (frontend) introduce keyboard shortcut
Inspired by Gmeet for the UX and by Jitsi for the code.
Naive implementation of Keyboard shortcuts listener.

Will be enhanced in the upcoming commits.
2024-09-27 16:34:32 +02:00
64 changed files with 2867 additions and 1425 deletions
+15 -2
View File
@@ -1,4 +1,5 @@
name: Docker Hub Workflow name: Docker Hub Workflow
run-name: Docker Hub Workflow
on: on:
workflow_dispatch: workflow_dispatch:
@@ -48,9 +49,15 @@ 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@v5 uses: docker/build-push-action@v6
with: with:
context: . context: .
target: backend-production target: backend-production
@@ -92,9 +99,15 @@ 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@v5 uses: docker/build-push-action@v6
with: with:
context: . context: .
file: ./src/frontend/Dockerfile file: ./src/frontend/Dockerfile
+1
View File
@@ -9,3 +9,4 @@ creation_rules:
- age1qy04neuzwpasmvljqrcvhwnf0kz5cpyteze38c8avp0czewskasszv9pyw #argocd - age1qy04neuzwpasmvljqrcvhwnf0kz5cpyteze38c8avp0czewskasszv9pyw #argocd
- age18fgn6j2vwwswqcpv9xpcehq8mrf9zs2sglwkamp3tzwx8d9jq9jsrskrk9 #manuuu - age18fgn6j2vwwswqcpv9xpcehq8mrf9zs2sglwkamp3tzwx8d9jq9jsrskrk9 #manuuu
- age1hm2hsfgjezpsc3k0y5w5feq9t8vl3seq04qjhgt6ztd6403wfvpsgxu09m # github-repo - age1hm2hsfgjezpsc3k0y5w5feq9t8vl3seq04qjhgt6ztd6403wfvpsgxu09m # github-repo
- age1hnhuzj96ktkhpyygvmz0x9h8mfvssz7ss6emmukags644mdhf4msajk93r # Samuel Paccoud
+16 -25
View File
@@ -1,15 +1,14 @@
# Django Meet # Django Meet
# ---- base image to inherit from ---- # ---- base image to inherit from ----
FROM python:3.10-slim-bullseye as base FROM python:3.12.6-alpine3.20 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 RUN python -m pip install --upgrade pip setuptools
# Upgrade system packages to install security updates # Upgrade system packages to install security updates
RUN apt-get update && \ RUN apk update && \
apt-get -y upgrade && \ apk 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
@@ -38,12 +37,9 @@ 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
# Install libpangocairo & rdfind RUN apk add \
RUN apt-get update && \ pango \
apt-get install -y \ rdfind
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
@@ -66,17 +62,14 @@ FROM base as core
ENV PYTHONUNBUFFERED=1 ENV PYTHONUNBUFFERED=1
# Install required system libs RUN apk add \
RUN apt-get update && \ gettext \
apt-get install -y \ cairo \
gettext \ libffi-dev \
libcairo2 \ gdk-pixbuf \
libffi-dev \ pango \
libgdk-pixbuf2.0-0 \ shared-mime-info
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
@@ -106,9 +99,7 @@ FROM core as backend-development
USER root:root USER root:root
# Install psql # Install psql
RUN apt-get update && \ RUN apk add postgresql-client
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
+1 -1
Submodule secrets updated: 8ef9f4513a...2ef2610071
+316
View File
@@ -0,0 +1,316 @@
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,6 +15,8 @@ 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__)
@@ -325,3 +327,24 @@ 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,
)
+2 -1
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 from core.api import get_frontend_configuration, viewsets, demo
from core.authentication.urls import urlpatterns as oidc_urls from core.authentication.urls import urlpatterns as oidc_urls
# - Main endpoints # - Main endpoints
@@ -24,6 +24,7 @@ urlpatterns = [
*router.urls, *router.urls,
*oidc_urls, *oidc_urls,
path("config/", get_frontend_configuration, name="config"), path("config/", get_frontend_configuration, name="config"),
path("minio-webhook/", demo.minio_webhook, name="demo"),
] ]
), ),
), ),
+1
View File
@@ -53,6 +53,7 @@ def generate_token(room: str, user, username: Optional[str] = None) -> str:
room=room, room=room,
room_join=True, room_join=True,
room_admin=True, room_admin=True,
room_record=True,
can_update_own_metadata=True, can_update_own_metadata=True,
can_publish_sources=[ can_publish_sources=[
"camera", "camera",
+69
View File
@@ -386,6 +386,53 @@ 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):
@@ -529,6 +576,20 @@ 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"),
@@ -549,6 +610,14 @@ 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
+2
View File
@@ -58,6 +58,8 @@ dependencies = [
"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]
+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.25 as frontend-production FROM nginxinc/nginx-unprivileged:1.26-alpine as frontend-production
# Un-privileged user running the application # Un-privileged user running the application
ARG DOCKER_USER ARG DOCKER_USER
+1179 -1098
View File
File diff suppressed because it is too large Load Diff
+17 -17
View File
@@ -13,23 +13,23 @@
"check": "prettier --check ./src" "check": "prettier --check ./src"
}, },
"dependencies": { "dependencies": {
"@livekit/components-react": "2.6.0", "@livekit/components-react": "2.6.5",
"@livekit/components-styles": "1.1.2", "@livekit/components-styles": "1.1.3",
"@livekit/track-processors": "0.3.2", "@livekit/track-processors": "0.3.2",
"@pandacss/preset-panda": "0.46.1", "@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.56.2", "@tanstack/react-query": "5.59.4",
"crisp-sdk-web": "1.0.25", "crisp-sdk-web": "1.0.25",
"hoofd": "1.7.1", "hoofd": "1.7.1",
"i18next": "23.15.1", "i18next": "23.15.2",
"i18next-browser-languagedetector": "8.0.0", "i18next-browser-languagedetector": "8.0.0",
"i18next-parser": "9.0.2", "i18next-parser": "9.0.2",
"i18next-resources-to-backend": "1.2.1", "i18next-resources-to-backend": "1.2.1",
"livekit-client": "2.5.3", "livekit-client": "2.5.7",
"posthog-js": "1.164.1", "posthog-js": "1.167.0",
"react": "18.3.1", "react": "18.3.1",
"react-aria-components": "1.3.3", "react-aria-components": "1.4.0",
"react-dom": "18.3.1", "react-dom": "18.3.1",
"react-i18next": "15.0.2", "react-i18next": "15.0.2",
"use-sound": "4.0.3", "use-sound": "4.0.3",
@@ -38,14 +38,14 @@
}, },
"devDependencies": { "devDependencies": {
"@pandacss/dev": "0.46.1", "@pandacss/dev": "0.46.1",
"@tanstack/eslint-plugin-query": "5.57.1", "@tanstack/eslint-plugin-query": "5.59.2",
"@tanstack/react-query-devtools": "5.56.2", "@tanstack/react-query-devtools": "5.59.4",
"@types/node": "20.16.6", "@types/node": "20.16.11",
"@types/react": "18.3.8", "@types/react": "18.3.11",
"@types/react-dom": "18.3.0", "@types/react-dom": "18.3.0",
"@typescript-eslint/eslint-plugin": "8.7.0", "@typescript-eslint/eslint-plugin": "8.8.1",
"@typescript-eslint/parser": "8.7.0", "@typescript-eslint/parser": "8.8.1",
"@vitejs/plugin-react": "4.3.1", "@vitejs/plugin-react": "4.3.2",
"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.10.0",
@@ -53,8 +53,8 @@
"eslint-plugin-react-refresh": "0.4.12", "eslint-plugin-react-refresh": "0.4.12",
"postcss": "8.4.47", "postcss": "8.4.47",
"prettier": "3.3.3", "prettier": "3.3.3",
"typescript": "5.6.2", "typescript": "5.6.3",
"vite": "5.4.5", "vite": "5.4.8",
"vite-tsconfig-paths": "5.0.1" "vite-tsconfig-paths": "5.0.1"
} }
} }
+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: '4px' }, '0%': { height: '25%' },
'25%': { height: '8px' }, '25%': { height: '45%' },
'50%': { height: '6px' }, '50%': { height: '20%' },
'100%': { height: '16px' }, '100%': { height: '55%' },
}, },
active_speake_small: { active_speaker_small: {
'0%': { height: '4px' }, '0%': { height: '20%' },
'25%': { height: '6px' }, '25%': { height: '25%' },
'50%': { height: '4px' }, '50%': { height: '18%' },
'100%': { height: '8px' }, '100%': { height: '25%' },
}, },
wave_hand: { wave_hand: {
'0%': { transform: 'rotate(0deg)' }, '0%': { transform: 'rotate(0deg)' },
+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 (
<Button <LinkButton
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"
/> />
</Button> </LinkButton>
) )
} }
@@ -34,19 +34,19 @@ export const MainNotificationToast = () => {
}, [room, triggerNotificationSound]) }, [room, triggerNotificationSound])
useEffect(() => { useEffect(() => {
const removeJoinNotification = (participant: Participant) => { const removeParticipantNotifications = (participant: Participant) => {
const existingToast = toastQueue.visibleToasts.find( toastQueue.visibleToasts.forEach((toast) => {
(toast) => if (toast.content.participant === participant) {
toast.content.participant === participant && toastQueue.close(toast.key)
toast.content.type === NotificationType.Joined }
) })
if (existingToast) {
toastQueue.close(existingToast.key)
}
} }
room.on(RoomEvent.ParticipantDisconnected, removeJoinNotification) room.on(RoomEvent.ParticipantDisconnected, removeParticipantNotifications)
return () => { return () => {
room.off(RoomEvent.ParticipantConnected, removeJoinNotification) room.off(
RoomEvent.ParticipantDisconnected,
removeParticipantNotifications
)
} }
}, [room]) }, [room])
@@ -105,7 +105,7 @@ export const MainNotificationToast = () => {
}, [room]) }, [room])
return ( return (
<Div position="absolute" bottom={20} right={5} zIndex={1000}> <Div position="absolute" bottom={0} 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 { useWidgetInteraction } from '@/features/rooms/livekit/hooks/useWidgetInteraction' import { useSidePanel } from '@/features/rooms/livekit/hooks/useSidePanel'
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 } = useWidgetInteraction() const { isParticipantsOpen, toggleParticipants } = useSidePanel()
return ( return (
<StyledToastContainer {...toastProps} ref={ref}> <StyledToastContainer {...toastProps} ref={ref}>
@@ -12,6 +12,19 @@ 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', {
@@ -36,16 +49,32 @@ const StyledChild = styled('div', {
}, },
size: { size: {
small: { small: {
animationName: 'active_speake_small', animationName: 'active_speaker_small',
},
},
pushToTalk: {
true: {
backgroundColor: 'primary',
width: '6px',
height: '6px',
}, },
}, },
}, },
}) })
export const ActiveSpeaker = ({ isSpeaking }: { isSpeaking: boolean }) => { export type ActiveSpeakerProps = {
isSpeaking: boolean
pushToTalk?: boolean
}
export const ActiveSpeaker = ({
isSpeaking,
pushToTalk,
}: ActiveSpeakerProps) => {
return ( return (
<StyledContainer> <StyledContainer pushToTalk={pushToTalk}>
<StyledChild <StyledChild
pushToTalk={pushToTalk}
active={isSpeaking} active={isSpeaking}
size="small" size="small"
style={{ style={{
@@ -53,12 +82,14 @@ export const ActiveSpeaker = ({ isSpeaking }: { isSpeaking: boolean }) => {
}} }}
/> />
<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,11 +1,7 @@
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 { import { LiveKitRoom, type LocalUserChoices } from '@livekit/components-react'
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'
@@ -104,7 +100,7 @@ export const Conference = ({
audio={userConfig.audioEnabled} audio={userConfig.audioEnabled}
video={userConfig.videoEnabled} video={userConfig.videoEnabled}
> >
<VideoConference chatMessageFormatter={formatChatMessageLinks} /> <VideoConference />
{showInviteDialog && ( {showInviteDialog && (
<InviteDialog <InviteDialog
isOpen={showInviteDialog} isOpen={showInviteDialog}
@@ -0,0 +1,53 @@
import { useRoomContext } from '@livekit/components-react'
import { useEffect, useState } from 'react'
import { RoomEvent } from 'livekit-client'
import { egressStore } from '@/stores/egress.ts'
import { useSnapshot } from 'valtio'
export const RecordingIndicator = () => {
const room = useRoomContext()
const [isRecording, setIsRecording] = useState(room.isRecording)
const egressSnap = useSnapshot(egressStore)
const egressIsStopping = egressSnap.egressIsStopping
useEffect(() => {
const handleRecordingStatusChanges = (isRecording: boolean) => {
if (!isRecording) {
egressStore.egressIsStopping = false
}
setIsRecording(isRecording)
}
room.on(RoomEvent.RecordingStatusChanged, handleRecordingStatusChanges)
return () => {
room.off(RoomEvent.RecordingStatusChanged, handleRecordingStatusChanges)
}
}, [room])
const getStatus = () => {
if (egressIsStopping) {
return 'saving recording'
}
if (isRecording) {
return 'recording'
}
if (!isRecording) {
return 'available'
}
}
return (
<div
style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
flexDirection: 'column',
width: '100%',
}}
>
Room status: {getStatus()}
</div>
)
}
@@ -0,0 +1,56 @@
import { fetchServerApi } from './fetchServerApi'
import { buildServerApiUrl } from './buildServerApiUrl'
import { useRoomData } from '../hooks/useRoomData'
import { useParams } from 'wouter'
export const useRecordRoom = () => {
const data = useRoomData()
const { roomId: roomSlug } = useParams()
const recordRoom = () => {
if (!data || !data?.livekit) {
throw new Error('Room data is not available')
}
if (!roomSlug) {
throw new Error('Room ID is not available')
}
return fetchServerApi(
buildServerApiUrl(
data.livekit.url,
'/twirp/livekit.Egress/StartRoomCompositeEgress'
),
data.livekit.token,
{
method: 'POST',
body: JSON.stringify({
room_name: data.livekit.room,
audio_only: true,
file_outputs: [
{
file_extension: 'ogg',
filepath: `{room_name}_{time}_${roomSlug}`,
},
],
}),
}
)
}
const stopRecordingRoom = (egressId: string) => {
if (!data || !data?.livekit) {
throw new Error('Room data is not available')
}
return fetchServerApi(
buildServerApiUrl(data.livekit.url, '/twirp/livekit.Egress/StopEgress'),
data.livekit.token,
{
method: 'POST',
body: JSON.stringify({
egressId,
}),
}
)
}
return { recordRoom, stopRecordingRoom }
}
@@ -94,7 +94,7 @@ export const Effects = () => {
} }
return ( return (
<VStack padding="0 1.5rem"> <VStack padding="0 1.5rem" overflowY="scroll">
{localCameraTrack && isCameraEnabled ? ( {localCameraTrack && isCameraEnabled ? (
<video <video
ref={videoRef} ref={videoRef}
@@ -1,4 +1,3 @@
import { useSnapshot } from 'valtio'
import { layoutStore } from '@/stores/layout' import { layoutStore } from '@/stores/layout'
import { css } from '@/styled-system/css' import { css } from '@/styled-system/css'
import { Heading } from 'react-aria-components' import { Heading } from 'react-aria-components'
@@ -7,14 +6,16 @@ import { Box, Button, Div } from '@/primitives'
import { RiCloseLine } from '@remixicon/react' import { RiCloseLine } from '@remixicon/react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { ParticipantsList } from './controls/Participants/ParticipantsList' import { ParticipantsList } from './controls/Participants/ParticipantsList'
import { useWidgetInteraction } from '../hooks/useWidgetInteraction' import { useSidePanel } from '../hooks/useSidePanel'
import { ReactNode } from 'react' import { ReactNode } from 'react'
import { Effects } from './Effects' import { Effects } from './Effects'
import { Chat } from '../prefabs/Chat'
type StyledSidePanelProps = { type StyledSidePanelProps = {
title: string title: string
children: ReactNode children: ReactNode
onClose: () => void onClose: () => void
isClosed: boolean
closeButtonTooltip: string closeButtonTooltip: string
} }
@@ -22,11 +23,11 @@ const StyledSidePanel = ({
title, title,
children, children,
onClose, onClose,
isClosed,
closeButtonTooltip, closeButtonTooltip,
}: StyledSidePanelProps) => ( }: StyledSidePanelProps) => (
<Box <Box
size="sm" size="sm"
minWidth="360px"
className={css({ className={css({
overflow: 'hidden', overflow: 'hidden',
display: 'flex', display: 'flex',
@@ -34,7 +35,16 @@ const StyledSidePanel = ({
margin: '1.5rem 1.5rem 1.5rem 0', margin: '1.5rem 1.5rem 1.5rem 0',
padding: 0, padding: 0,
gap: 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 <Heading
slot="title" slot="title"
@@ -43,11 +53,19 @@ const StyledSidePanel = ({
style={{ style={{
paddingLeft: '1.5rem', paddingLeft: '1.5rem',
paddingTop: '1rem', paddingTop: '1rem',
display: isClosed ? 'none' : undefined,
}} }}
> >
{title} {title}
</Heading> </Heading>
<Div position="absolute" top="5" right="5"> <Div
position="absolute"
top="5"
right="5"
style={{
display: isClosed ? 'none' : undefined,
}}
>
<Button <Button
invisible invisible
size="xs" size="xs"
@@ -58,31 +76,56 @@ const StyledSidePanel = ({
<RiCloseLine /> <RiCloseLine />
</Button> </Button>
</Div> </Div>
<Div overflowY="scroll">{children}</Div> {children}
</Box> </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 = () => { export const SidePanel = () => {
const layoutSnap = useSnapshot(layoutStore) const {
const sidePanel = layoutSnap.sidePanel activePanelId,
isParticipantsOpen,
const { isParticipantsOpen, isEffectsOpen } = useWidgetInteraction() isEffectsOpen,
isChatOpen,
isSidePanelOpen,
} = useSidePanel()
const { t } = useTranslation('rooms', { keyPrefix: 'sidePanel' }) const { t } = useTranslation('rooms', { keyPrefix: 'sidePanel' })
if (!sidePanel) {
return
}
return ( return (
<StyledSidePanel <StyledSidePanel
title={t(`heading.${sidePanel}`)} title={t(`heading.${activePanelId}`)}
onClose={() => (layoutStore.sidePanel = null)} onClose={() => (layoutStore.activePanelId = null)}
closeButtonTooltip={t('closeButton', { closeButtonTooltip={t('closeButton', {
content: t(`content.${sidePanel}`), content: t(`content.${activePanelId}`),
})} })}
isClosed={!isSidePanelOpen}
> >
{isParticipantsOpen && <ParticipantsList />} <Panel isOpen={isParticipantsOpen}>
{isEffectsOpen && <Effects />} <ParticipantsList />
</Panel>
<Panel isOpen={isEffectsOpen}>
<Effects />
</Panel>
<Panel isOpen={isChatOpen}>
<Chat />
</Panel>
</StyledSidePanel> </StyledSidePanel>
) )
} }
@@ -0,0 +1,71 @@
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>
)
})
@@ -0,0 +1,120 @@
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,13 +1,17 @@
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { RiChat1Line } from '@remixicon/react' import { RiChat1Line } from '@remixicon/react'
import { ToggleButton } from '@/primitives' import { useSnapshot } from 'valtio'
import { css } from '@/styled-system/css' import { css } from '@/styled-system/css'
import { useWidgetInteraction } from '../../hooks/useWidgetInteraction' import { ToggleButton } from '@/primitives'
import { chatStore } from '@/stores/chat'
import { useSidePanel } from '../../hooks/useSidePanel'
export const ChatToggle = () => { export const ChatToggle = () => {
const { t } = useTranslation('rooms', { keyPrefix: 'controls.chat' }) const { t } = useTranslation('rooms', { keyPrefix: 'controls.chat' })
const { isChatOpen, unreadMessages, toggleChat } = useWidgetInteraction() const chatSnap = useSnapshot(chatStore)
const { isChatOpen, toggleChat } = useSidePanel()
const tooltipLabel = isChatOpen ? 'open' : 'closed' const tooltipLabel = isChatOpen ? 'open' : 'closed'
return ( return (
@@ -28,7 +32,7 @@ export const ChatToggle = () => {
> >
<RiChat1Line /> <RiChat1Line />
</ToggleButton> </ToggleButton>
{!!unreadMessages && ( {!!chatSnap.unreadMessages && (
<div <div
className={css({ className={css({
position: 'absolute', position: 'absolute',
@@ -1,7 +1,7 @@
import { menuItemRecipe } from '@/primitives/menuItemRecipe' import { menuItemRecipe } from '@/primitives/menuItemRecipe'
import { import {
RiAccountBoxLine, RiAccountBoxLine,
RiFeedbackLine, RiMegaphoneLine,
RiSettings3Line, RiSettings3Line,
} from '@remixicon/react' } from '@remixicon/react'
import { MenuItem, Menu as RACMenu, Section } from 'react-aria-components' import { MenuItem, Menu as RACMenu, Section } from 'react-aria-components'
@@ -9,7 +9,8 @@ import { useTranslation } from 'react-i18next'
import { Dispatch, SetStateAction } from 'react' import { Dispatch, SetStateAction } from 'react'
import { DialogState } from './OptionsButton' import { DialogState } from './OptionsButton'
import { Separator } from '@/primitives/Separator' import { Separator } from '@/primitives/Separator'
import { useWidgetInteraction } from '../../../hooks/useWidgetInteraction' 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,7 +19,7 @@ export const OptionsMenuItems = ({
onOpenDialog: Dispatch<SetStateAction<DialogState>> onOpenDialog: Dispatch<SetStateAction<DialogState>>
}) => { }) => {
const { t } = useTranslation('rooms', { keyPrefix: 'options.items' }) const { t } = useTranslation('rooms', { keyPrefix: 'options.items' })
const { toggleEffects } = useWidgetInteraction() const { toggleEffects } = useSidePanel()
return ( return (
<RACMenu <RACMenu
style={{ style={{
@@ -34,6 +35,7 @@ export const OptionsMenuItems = ({
<RiAccountBoxLine size={20} /> <RiAccountBoxLine size={20} />
{t('effects')} {t('effects')}
</MenuItem> </MenuItem>
<RecordingMenuItem />
</Section> </Section>
<Separator /> <Separator />
<Section> <Section>
@@ -42,7 +44,7 @@ export const OptionsMenuItems = ({
target="_blank" target="_blank"
className={menuItemRecipe({ icon: true })} className={menuItemRecipe({ icon: true })}
> >
<RiFeedbackLine size={20} /> <RiMegaphoneLine size={20} />
{t('feedbacks')} {t('feedbacks')}
</MenuItem> </MenuItem>
<MenuItem <MenuItem
@@ -0,0 +1,52 @@
import { MenuItem } from 'react-aria-components'
import { menuItemRecipe } from '@/primitives/menuItemRecipe.ts'
import { RiPauseCircleLine, RiRecordCircleLine } from '@remixicon/react'
import { useRecordRoom } from '@/features/rooms/livekit/api/recordRoom.ts'
import { useState } from 'react'
import { egressStore } from '@/stores/egress.ts'
import { useSnapshot } from 'valtio'
export const RecordingMenuItem = () => {
const { recordRoom, stopRecordingRoom } = useRecordRoom()
const egressSnap = useSnapshot(egressStore)
const egressId = egressSnap.egressId
const [isPending, setIsPending] = useState(false)
const handleAction = async () => {
if (egressId) {
setIsPending(true)
egressStore.egressIsStopping = true
const response = await stopRecordingRoom(egressId)
console.log(response)
egressStore.egressId = undefined
setIsPending(false)
} else {
setIsPending(true)
const response = await recordRoom()
egressStore.egressId = response['egress_id'] as string
setIsPending(false)
}
}
return (
<MenuItem
isDisabled={isPending}
className={menuItemRecipe({ icon: true })}
onAction={handleAction}
>
{egressId ? (
<>
<RiPauseCircleLine size={18} />
Arrêter l'enregistrement
</>
) : (
<>
<RiRecordCircleLine size={18} />
Enregistrer la réunion
</>
)}
</MenuItem>
)
}
@@ -1,5 +1,4 @@
import { css } from '@/styled-system/css' import { css } from '@/styled-system/css'
import { allParticipantRoomEvents } from '@livekit/components-core'
import { useParticipants } from '@livekit/components-react' import { useParticipants } from '@livekit/components-react'
import { Div, H } from '@/primitives' import { Div, H } from '@/primitives'
@@ -8,7 +7,6 @@ import { ParticipantListItem } from '../../controls/Participants/ParticipantList
import { ParticipantsCollapsableList } from '../../controls/Participants/ParticipantsCollapsableList' import { ParticipantsCollapsableList } from '../../controls/Participants/ParticipantsCollapsableList'
import { HandRaisedListItem } from '../../controls/Participants/HandRaisedListItem' import { HandRaisedListItem } from '../../controls/Participants/HandRaisedListItem'
import { LowerAllHandsButton } from '../../controls/Participants/LowerAllHandsButton' import { LowerAllHandsButton } from '../../controls/Participants/LowerAllHandsButton'
import { RoomEvent } from 'livekit-client'
// 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 = () => {
@@ -17,12 +15,7 @@ export const ParticipantsList = () => {
// 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: [
RoomEvent.ParticipantNameChanged,
...allParticipantRoomEvents,
],
})
const sortedRemoteParticipants = participants const sortedRemoteParticipants = participants
.slice(1) .slice(1)
@@ -44,7 +37,7 @@ 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">
<H <H
lvl={2} lvl={2}
className={css({ className={css({
@@ -78,6 +71,6 @@ export const ParticipantsList = () => {
<ParticipantListItem participant={participant} /> <ParticipantListItem participant={participant} />
)} )}
/> />
</> </Div>
) )
} }
@@ -3,7 +3,7 @@ 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 { useWidgetInteraction } from '../../../hooks/useWidgetInteraction' import { useSidePanel } from '../../../hooks/useSidePanel'
export const ParticipantsToggle = () => { export const ParticipantsToggle = () => {
const { t } = useTranslation('rooms', { keyPrefix: 'controls.participants' }) const { t } = useTranslation('rooms', { keyPrefix: 'controls.participants' })
@@ -16,7 +16,7 @@ export const ParticipantsToggle = () => {
const participants = useParticipants() const participants = useParticipants()
const numParticipants = participants?.length const numParticipants = participants?.length
const { isParticipantsOpen, toggleParticipants } = useWidgetInteraction() const { isParticipantsOpen, toggleParticipants } = useSidePanel()
const tooltipLabel = isParticipantsOpen ? 'open' : 'closed' const tooltipLabel = isParticipantsOpen ? 'open' : 'closed'
@@ -5,7 +5,7 @@ import {
UseTrackToggleProps, UseTrackToggleProps,
} from '@livekit/components-react' } from '@livekit/components-react'
import { HStack } from '@/styled-system/jsx' import { HStack } from '@/styled-system/jsx'
import { Button, Menu, MenuList, ToggleButton } from '@/primitives' import { Button, Menu, MenuList } from '@/primitives'
import { import {
RemixiconComponentType, RemixiconComponentType,
RiArrowDownSLine, RiArrowDownSLine,
@@ -15,7 +15,10 @@ import {
RiVideoOnLine, RiVideoOnLine,
} from '@remixicon/react' } from '@remixicon/react'
import { Track } from 'livekit-client' import { Track } from 'livekit-client'
import React from 'react'
import { Shortcut } from '@/features/shortcuts/types'
import { ToggleDevice } from '@/features/rooms/livekit/components/controls/ToggleDevice.tsx'
export type ToggleSource = Exclude< export type ToggleSource = Exclude<
Track.Source, Track.Source,
@@ -24,10 +27,12 @@ export type ToggleSource = Exclude<
type SelectToggleSource = Exclude<ToggleSource, Track.Source.ScreenShare> type SelectToggleSource = Exclude<ToggleSource, Track.Source.ScreenShare>
type SelectToggleDeviceConfig = { export type SelectToggleDeviceConfig = {
kind: MediaDeviceKind kind: MediaDeviceKind
iconOn: RemixiconComponentType iconOn: RemixiconComponentType
iconOff: RemixiconComponentType iconOff: RemixiconComponentType
shortcut?: Shortcut
longPress?: Shortcut
} }
type SelectToggleDeviceConfigMap = { type SelectToggleDeviceConfigMap = {
@@ -39,11 +44,22 @@ const selectToggleDeviceConfig: SelectToggleDeviceConfigMap = {
kind: 'audioinput', kind: 'audioinput',
iconOn: RiMicLine, iconOn: RiMicLine,
iconOff: RiMicOffLine, iconOff: RiMicOffLine,
shortcut: {
key: 'd',
ctrlKey: true,
},
longPress: {
key: 'Space',
},
}, },
[Track.Source.Camera]: { [Track.Source.Camera]: {
kind: 'videoinput', kind: 'videoinput',
iconOn: RiVideoOnLine, iconOn: RiVideoOnLine,
iconOff: RiVideoOffLine, iconOff: RiVideoOffLine,
shortcut: {
key: 'e',
ctrlKey: true,
},
}, },
} }
@@ -61,39 +77,17 @@ export const SelectToggleDevice = <T extends ToggleSource>({
if (!config) { if (!config) {
throw new Error('Invalid source') throw new Error('Invalid source')
} }
const { t } = useTranslation('rooms', { keyPrefix: 'join' }) const { t } = useTranslation('rooms', { keyPrefix: 'join' })
const { buttonProps, enabled } = useTrackToggle(props) const trackProps = useTrackToggle(props)
const { kind, iconOn, iconOff } = config
const { devices, activeDeviceId, setActiveMediaDevice } = const { devices, activeDeviceId, setActiveMediaDevice } =
useMediaDeviceSelect({ kind }) useMediaDeviceSelect({ kind: config.kind })
const toggleLabel = t(enabled ? 'disable' : 'enable', { const selectLabel = t('choose', { keyPrefix: `join.${config.kind}` })
keyPrefix: `join.${kind}`,
})
const selectLabel = t('choose', { keyPrefix: `join.${kind}` })
const Icon = enabled ? iconOn : iconOff
return ( return (
<HStack gap={0}> <HStack gap={0}>
<ToggleButton <ToggleDevice {...trackProps} config={config} />
isSelected={enabled}
variant={enabled ? undefined : 'danger'}
toggledStyles={false}
onPress={(e) =>
buttonProps.onClick?.(
e as unknown as React.MouseEvent<HTMLButtonElement>
)
}
aria-label={toggleLabel}
tooltip={toggleLabel}
groupPosition="left"
>
<Icon />
</ToggleButton>
<Menu> <Menu>
<Button <Button
tooltip={selectLabel} tooltip={selectLabel}
@@ -0,0 +1,71 @@
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,41 @@
import { useSnapshot } from 'valtio'
import { layoutStore } from '@/stores/layout'
export enum PanelId {
PARTICIPANTS = 'participants',
EFFECTS = 'effects',
CHAT = 'chat',
}
export const useSidePanel = () => {
const layoutSnap = useSnapshot(layoutStore)
const activePanelId = layoutSnap.activePanelId
const isParticipantsOpen = activePanelId == PanelId.PARTICIPANTS
const isEffectsOpen = activePanelId == PanelId.EFFECTS
const isChatOpen = activePanelId == PanelId.CHAT
const isSidePanelOpen = !!activePanelId
const toggleParticipants = () => {
layoutStore.activePanelId = isParticipantsOpen ? null : PanelId.PARTICIPANTS
}
const toggleChat = () => {
layoutStore.activePanelId = isChatOpen ? null : PanelId.CHAT
}
const toggleEffects = () => {
layoutStore.activePanelId = isEffectsOpen ? null : PanelId.EFFECTS
}
return {
activePanelId,
toggleParticipants,
toggleChat,
toggleEffects,
isChatOpen,
isParticipantsOpen,
isEffectsOpen,
isSidePanelOpen,
}
}
@@ -1,46 +0,0 @@
import { useLayoutContext } from '@livekit/components-react'
import { useSnapshot } from 'valtio'
import { layoutStore } from '@/stores/layout'
export const useWidgetInteraction = () => {
const { dispatch, state } = useLayoutContext().widget
const layoutSnap = useSnapshot(layoutStore)
const sidePanel = layoutSnap.sidePanel
const isParticipantsOpen = sidePanel == 'participants'
const isEffectsOpen = sidePanel == 'effects'
const toggleParticipants = () => {
if (dispatch && state?.showChat) {
dispatch({ msg: 'toggle_chat' })
}
layoutStore.sidePanel = isParticipantsOpen ? null : 'participants'
}
const toggleChat = () => {
if (isParticipantsOpen || isEffectsOpen) {
layoutStore.sidePanel = null
}
if (dispatch) {
dispatch({ msg: 'toggle_chat' })
}
}
const toggleEffects = () => {
if (dispatch && state?.showChat) {
dispatch({ msg: 'toggle_chat' })
}
layoutStore.sidePanel = isEffectsOpen ? null : 'effects'
}
return {
toggleParticipants,
toggleChat,
toggleEffects,
isChatOpen: state?.showChat,
unreadMessages: state?.unreadMessages,
isParticipantsOpen,
isEffectsOpen,
}
}
@@ -0,0 +1,134 @@
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>
)
}
@@ -8,7 +8,6 @@ import {
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 { OptionsButton } from '../components/controls/Options/OptionsButton' import { OptionsButton } from '../components/controls/Options/OptionsButton'
@@ -18,6 +17,7 @@ import { HandToggle } from '../components/controls/HandToggle'
import { SelectToggleDevice } from '../components/controls/SelectToggleDevice' import { SelectToggleDevice } from '../components/controls/SelectToggleDevice'
import { LeaveButton } from '../components/controls/LeaveButton' import { LeaveButton } from '../components/controls/LeaveButton'
import { ScreenShareToggle } from '../components/controls/ScreenShareToggle' import { ScreenShareToggle } from '../components/controls/ScreenShareToggle'
import { css } from '@/styled-system/css'
/** @public */ /** @public */
export type ControlBarControls = { export type ControlBarControls = {
@@ -63,7 +63,6 @@ export function ControlBar({
variation, variation,
saveUserChoices = true, saveUserChoices = true,
onDeviceError, onDeviceError,
...props
}: ControlBarProps) { }: ControlBarProps) {
const [isChatOpen, setIsChatOpen] = React.useState(false) const [isChatOpen, setIsChatOpen] = React.useState(false)
const layoutContext = useMaybeLayoutContext() const layoutContext = useMaybeLayoutContext()
@@ -82,8 +81,6 @@ export function ControlBar({
const browserSupportsScreenSharing = supportsScreenSharing() const browserSupportsScreenSharing = supportsScreenSharing()
const htmlProps = mergeProps({ className: 'lk-control-bar' }, props)
const { const {
saveAudioInputEnabled, saveAudioInputEnabled,
saveVideoInputEnabled, saveVideoInputEnabled,
@@ -104,7 +101,23 @@ export function ControlBar({
) )
return ( return (
<div {...htmlProps}> <div
className={css({
display: 'flex',
gap: '.5rem',
alignItems: 'center',
justifyContent: 'center',
padding: '.75rem',
borderTop: '1px solid var(--lk-border-color)',
maxHeight: 'var(--lk-control-bar-height)',
height: '80px',
position: 'absolute',
backgroundColor: '#d1d5db',
bottom: 0,
left: 0,
right: 0,
})}
>
<SelectToggleDevice <SelectToggleDevice
source={Track.Source.Microphone} source={Track.Source.Microphone}
onChange={microphoneOnChange} onChange={microphoneOnChange}
@@ -1,9 +1,4 @@
import type { import type { TrackReferenceOrPlaceholder } from '@livekit/components-core'
MessageDecoder,
MessageEncoder,
TrackReferenceOrPlaceholder,
WidgetState,
} from '@livekit/components-core'
import { import {
isEqualTrackRef, isEqualTrackRef,
isTrackReference, isTrackReference,
@@ -20,22 +15,20 @@ 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 { useSnapshot } from 'valtio' import { MainNotificationToast } from '@/features/notifications/MainNotificationToast'
import { layoutStore } from '@/stores/layout'
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 { SidePanel } from '../components/SidePanel'
import { MainNotificationToast } from '@/features/notifications/MainNotificationToast' import { useSidePanel } from '../hooks/useSidePanel'
import { RecordingIndicator } from '@/features/rooms/components/RecordingIndicator.tsx'
const LayoutWrapper = styled( const LayoutWrapper = styled(
'div', 'div',
@@ -44,7 +37,7 @@ const LayoutWrapper = styled(
position: 'relative', position: 'relative',
display: 'flex', display: 'flex',
width: '100%', width: '100%',
height: 'calc(100% - var(--lk-control-bar-height))', height: '100%',
}, },
}) })
) )
@@ -54,9 +47,6 @@ 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
} }
@@ -79,17 +69,7 @@ export interface VideoConferenceProps
* ``` * ```
* @public * @public
*/ */
export function VideoConference({ export function VideoConference({ ...props }: VideoConferenceProps) {
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)
@@ -101,11 +81,6 @@ export function VideoConference({
{ 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
@@ -172,8 +147,7 @@ export function VideoConference({
]) ])
/* eslint-enable react-hooks/exhaustive-deps */ /* eslint-enable react-hooks/exhaustive-deps */
const layoutSnap = useSnapshot(layoutStore) const { isSidePanelOpen } = useSidePanel()
const sidePanel = layoutSnap.sidePanel
return ( return (
<div className="lk-video-conference" {...props}> <div className="lk-video-conference" {...props}>
@@ -181,9 +155,18 @@ export function VideoConference({
<LayoutContextProvider <LayoutContextProvider
value={layoutContext} value={layoutContext}
// onPinChange={handleFocusStateChange} // onPinChange={handleFocusStateChange}
onWidgetChange={widgetUpdate}
> >
<div className="lk-video-conference-inner"> <div
// todo - extract these magic values into constant
style={{
position: 'absolute',
inset: isSidePanelOpen
? 'var(--lk-grid-gap) calc(358px + 3rem) calc(80px + var(--lk-grid-gap)) 16px'
: 'var(--lk-grid-gap) var(--lk-grid-gap) calc(80px + var(--lk-grid-gap))',
transition: 'inset .5s cubic-bezier(0.4,0,0.2,1) 5ms',
}}
>
<RecordingIndicator />
<LayoutWrapper> <LayoutWrapper>
<div <div
style={{ display: 'flex', position: 'relative', width: '100%' }} style={{ display: 'flex', position: 'relative', width: '100%' }}
@@ -193,7 +176,7 @@ export function VideoConference({
className="lk-grid-layout-wrapper" className="lk-grid-layout-wrapper"
style={{ height: 'auto' }} style={{ height: 'auto' }}
> >
<GridLayout tracks={tracks}> <GridLayout tracks={tracks} style={{ padding: 0 }}>
<ParticipantTile /> <ParticipantTile />
</GridLayout> </GridLayout>
</div> </div>
@@ -202,7 +185,7 @@ export function VideoConference({
className="lk-focus-layout-wrapper" className="lk-focus-layout-wrapper"
style={{ height: 'auto' }} style={{ height: 'auto' }}
> >
<FocusLayoutContainer> <FocusLayoutContainer style={{ padding: 0 }}>
<CarouselLayout <CarouselLayout
tracks={carouselTracks} tracks={carouselTracks}
style={{ style={{
@@ -215,18 +198,12 @@ export function VideoConference({
</FocusLayoutContainer> </FocusLayoutContainer>
</div> </div>
)} )}
<MainNotificationToast />
</div> </div>
<Chat
style={{ display: widgetState.showChat ? 'grid' : 'none' }}
messageFormatter={chatMessageFormatter}
messageEncoder={chatMessageEncoder}
messageDecoder={chatMessageDecoder}
/>
{sidePanel && <SidePanel />}
</LayoutWrapper> </LayoutWrapper>
<ControlBar /> <MainNotificationToast />
</div> </div>
<ControlBar />
<SidePanel />
</LayoutContextProvider> </LayoutContextProvider>
)} )}
<RoomAudioRenderer /> <RoomAudioRenderer />
@@ -8,6 +8,7 @@ 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()
@@ -19,6 +20,8 @@ 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({}, '')
@@ -0,0 +1,4 @@
export type Shortcut = {
key: string
ctrlKey?: boolean
}
@@ -0,0 +1,31 @@
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])
}
@@ -0,0 +1,47 @@
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
@@ -0,0 +1,19 @@
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])
}
@@ -0,0 +1,18 @@
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})`
}
+17 -3
View File
@@ -47,7 +47,16 @@
"stopScreenShare": "", "stopScreenShare": "",
"chat": { "chat": {
"open": "", "open": "",
"closed": "" "closed": "",
"input": {
"textArea": {
"label": "",
"placeholder": ""
},
"button": {
"label": ""
}
}
}, },
"hand": { "hand": {
"raise": "", "raise": "",
@@ -88,14 +97,19 @@
"sidePanel": { "sidePanel": {
"heading": { "heading": {
"participants": "", "participants": "",
"effects": "" "effects": "",
"chat": ""
}, },
"content": { "content": {
"participants": "", "participants": "",
"effects": "" "effects": "",
"chat": ""
}, },
"closeButton": "" "closeButton": ""
}, },
"chat": {
"disclaimer": ""
},
"participants": { "participants": {
"subheading": "", "subheading": "",
"contributors": "", "contributors": "",
+17 -3
View File
@@ -45,7 +45,16 @@
"camera": "Camera", "camera": "Camera",
"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",
@@ -86,14 +95,19 @@
"sidePanel": { "sidePanel": {
"heading": { "heading": {
"participants": "Participants", "participants": "Participants",
"effects": "Effects" "effects": "Effects",
"chat": "Messages in the chat"
}, },
"content": { "content": {
"participants": "participants", "participants": "participants",
"effects": "effects" "effects": "effects",
"chat": "messages"
}, },
"closeButton": "Hide {{content}}" "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": {
"subheading": "In room", "subheading": "In room",
"you": "You", "you": "You",
+17 -3
View File
@@ -45,7 +45,16 @@
"camera": "Camera", "camera": "Camera",
"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",
@@ -86,14 +95,19 @@
"sidePanel": { "sidePanel": {
"heading": { "heading": {
"participants": "Participants", "participants": "Participants",
"effects": "Effets" "effects": "Effets",
"chat": "Messages dans l'appel"
}, },
"content": { "content": {
"participants": "les participants", "participants": "les participants",
"effects": "les effets" "effects": "les effets",
"chat": "les messages"
}, },
"closeButton": "Masquer {{content}}" "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": {
"subheading": "Dans la réunion", "subheading": "Dans la réunion",
"you": "Vous", "you": "Vous",
+1 -16
View File
@@ -1,8 +1,6 @@
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'
@@ -12,25 +10,12 @@ 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
}: ButtonOrLinkProps) => { }: ButtonProps) => {
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}>
@@ -0,0 +1,22 @@
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>
)
}
+27
View File
@@ -0,0 +1,27 @@
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,6 +117,9 @@ export const buttonRecipe = cva({
'&[data-pressed]': { '&[data-pressed]': {
borderColor: 'currentcolor', borderColor: 'currentcolor',
}, },
'&[data-disabled]': {
color: 'gray.300',
},
}, },
}, },
fullWidth: { fullWidth: {
+1
View File
@@ -9,6 +9,7 @@ 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
@@ -0,0 +1,9 @@
import { proxy } from 'valtio'
type State = {
unreadMessages: number
}
export const chatStore = proxy<State>({
unreadMessages: 0,
})
+11
View File
@@ -0,0 +1,11 @@
import { proxy } from 'valtio'
type State = {
egressId: string | undefined
egressIsStopping: boolean
}
export const egressStore = proxy<State>({
egressId: undefined,
egressIsStopping: false,
})
@@ -0,0 +1,11 @@
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 -2
View File
@@ -1,11 +1,12 @@
import { proxy } from 'valtio' import { proxy } from 'valtio'
import { PanelId } from '@/features/rooms/livekit/hooks/useSidePanel'
type State = { type State = {
showHeader: boolean showHeader: boolean
sidePanel: 'participants' | 'effects' | null activePanelId: PanelId | null
} }
export const layoutStore = proxy<State>({ export const layoutStore = proxy<State>({
showHeader: false, showHeader: false,
sidePanel: null, activePanelId: null,
}) })
+4
View File
@@ -25,3 +25,7 @@ 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
}
+59 -50
View File
@@ -1,14 +1,14 @@
djangoSecretKey: ENC[AES256_GCM,data:dqeL5W5WaTir+tzUAIbivT1DE0zv1IVsq1/nTU7LRKHO2PUefwsyGAy37eYIeC89q9I=,iv:ODTaWFvvYLHNYRkIX//wY2knKD2uIwf7Mo4UhUBEX9c=,tag:MnE3wLdLcK79XUwXV99w4w==,type:str] djangoSecretKey: ENC[AES256_GCM,data:p+9m8eNB/dKMXAdfL0cVCg1uKhAv+YLrM+jjajvRYmOZZ9qbiikuFv0dyDp32va/M9w=,iv:ijUztg7ta6BBTsKs+IIfJMFdN0DfzyAKoxlfY8lisPg=,tag:B+uW6akIV0iI2LdMQotrpw==,type:str]
oidc: oidc:
clientId: ENC[AES256_GCM,data:X+Qj//JXn9kfVszbe9BNbL8rQ53AJq0Cluq3nG58OyXlv5/y,iv:/Na7zvcsIu5OxJw1uFwiksh8tXfzG9It26jW41E0edQ=,tag:oph41uyMwj3uZMnZPI64ZQ==,type:str] clientId: ENC[AES256_GCM,data:rHzKkQwFQ7hV6kOBBP60RK41NBKVMUs4dMcZavMQ8gCu9ust,iv:8vviSb+XIKS/zjBIScfmWu0VJ8lXCQZ8p7BxuvJtA2w=,tag:k8vn8I/qxKLE/+JNTDj4Jw==,type:str]
clientSecret: ENC[AES256_GCM,data:2WV+i0pd4wBXiwG8ak+yVROGsmwSaM0X0rEJjFeNeGYtUsHozd5XrurQu6FAB1tQnzTRgRgAU0ob0MqbsMUYhg==,iv:OJZRG+OUD0Fi2HZ0w3Rn+SMFkebMrsQtKvymJkicF28=,tag:PJYzhVvM0gH0xAGrSSPAIQ==,type:str] clientSecret: ENC[AES256_GCM,data:dOYJoG2PStlOMIJPi2exPzsqlxis73iTkcBMvjr8DBr2isWzstpbexscsog7Tuyelw4tpzrJKzC5BTTwJ+xioQ==,iv:oqkLRTPB8+qR0AHvjyNVfHRmoeGrkUvZjrTsWBjIeBc=,tag:hryfmSeqkdWCN9U38jxXlA==,type:str]
#ENC[AES256_GCM,data:iZjWgYcHtDeK/c1TrfVVsZaJRmuc1+2XEJnLEOYw1wXMsUUvgswEc5PP/4UmirhbSulwtuMRhFt74sIogMxPl2etMAoCqo3ziKRuu3FrmgSUD/bdXQRl+VQ0nJ3NnWNcKqf+GS8=,iv:5hUaA4hkABLvXoKVE4TNZevOdUCaCy3zYkGajHpmZ5k=,tag:ikiSAyIly8NAgVA0olASRw==,type:comment] #ENC[AES256_GCM,data:ua1td/VBXGIHDgAw/bm8XnWIRLmgeJKX9dP7g/rNv3jVsXHw6T+iDXxMWpLXNicAZ/RTymdntlwLwsH47r70Z4icEPsjps0yOZ+X734vaL9wVH9IsyFwCihtyck94kgY4CyC7DI=,iv:iGHYu+2aPaI28PQWFheVVuge8BPWLw1VB7Afsz7eLtI=,tag:pfkXsS+/QmHb3kHS/ONHCA==,type:comment]
livekit: livekit:
keys: keys:
devkey: ENC[AES256_GCM,data:deDMTTO9,iv:ZCloCUPRJ7PG2KcGsnhVj3lRDfCmJowwp8PVg4fdTmg=,tag:zCh8lQT29kQbY7LiXHNEiA==,type:str] devkey: ENC[AES256_GCM,data:5RnAMGm3,iv:bY4n8op2KFlXRqzV9h3QwoC3Bws2aEoN1GFxPlrrVBw=,tag:lA+b/6poVRzeJW6Bu8V29A==,type:str]
livekitApi: livekitApi:
key: ENC[AES256_GCM,data:fh0zJSUE,iv:yR5siulW5vlO/Ew8qtYFNNtyODOPm3d3NL4SVCzzVcM=,tag:hLtEuonLJoi0eFJmY0RYEg==,type:str] key: ENC[AES256_GCM,data:JP7KkPms,iv:LlIJ62IRyGf8fByl6abSZv1to2FUc90laC0oL5HFJK4=,tag:2aLMQ79GlDOaiurh8unO0Q==,type:str]
secret: ENC[AES256_GCM,data:81SOHzA6,iv:is7QdB/T58IP41SrPyDdFHtyX57tphb9Gpf/aQ7NnTY=,tag:1E03R+EFYmLtxFoIDcVEHg==,type:str] secret: ENC[AES256_GCM,data:kGDJo1lh,iv:dnI1OuvZGOJZEKZwzoigXqViqYCw/6H7Y0sVXH/p5RA=,tag:G1IB0mc8zuKEmkxrfyImrQ==,type:str]
sops: sops:
kms: [] kms: []
gcp_kms: [] gcp_kms: []
@@ -18,77 +18,86 @@ sops:
- recipient: age15fyxdwmg5mvldtqqus87xspuws2u0cpvwheehrtvkexj4tnsqqysw6re2x - recipient: age15fyxdwmg5mvldtqqus87xspuws2u0cpvwheehrtvkexj4tnsqqysw6re2x
enc: | enc: |
-----BEGIN AGE ENCRYPTED FILE----- -----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBCQnRJY0xWNFVRYkR0QTND YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSByR3IybDN3eGx4amYzZkFt
Rkc5dDRITUpRMW1EVlhEUk9xN0ttbElrdEJvClBMVnVKMXhGMThTcTVaN0s3OG1v OW5VV3FQN3dkSmZBL0JwUE1qSzNLYmRTc1RjCkVCQ2ZmaHk2SFRJaXdMd0VMZUlP
S0U2UUNYZ3FzcXUwVXRyS1hPZkpxcmsKLS0tIHhWei9KVmxFaXgzRUxmMWU2RmhG b0VQeDVUTDBEZzhBQnhrS2RybzYvL1UKLS0tIG1CbllhWGpsOWx4WEkya0NLeUlC
Y1ZQekpQS3poSWxHZXUvbitlc2lIczAKjLZlgUSz51W4GHirUn352eFPSxIK1/Wf WmRScW9MVkxQLzRxdk85WTZ4U2E0aUUKTpOPYQXutU0xYLih7SNYoQgO+PSEIERL
N+kzoUvMLmwwfHDztFFYLOEE9x1zx7GoPGG7fN9bTG7GWgcRdd7NUw== HLz+C7iV+Fj1/M7JrgiGxTB8wJoKMo7IhJ8AjxaAdxR4Q1TgUpQkPw==
-----END AGE ENCRYPTED FILE----- -----END AGE ENCRYPTED FILE-----
- recipient: age16hnlml8yv4ynwy0seer57g8qww075crd0g7nsundz3pj4wk7m3vqftszg7 - recipient: age16hnlml8yv4ynwy0seer57g8qww075crd0g7nsundz3pj4wk7m3vqftszg7
enc: | enc: |
-----BEGIN AGE ENCRYPTED FILE----- -----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSA3aTVDMDNUUFFVQlE3Ykpl YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSA0aE15QkRsNmg2UTkxaWNF
ZlFQUHQ0UUdlTU54WlEvanBWeGVlYnd1aEZFClQ3Y1ZSOHp6QUt1UXBLV3NWaGJ4 T3NZY2RqSDd0WlRKOHYxWFE2R3J5SGJhRjJNClNIcEFwOEtoSmRWQjdaSm1ZSnlj
T2ZwKzZXcXh1Z2llQk9SN1RRLzNOdGcKLS0tIHN0YTFpL05SQUc1WTRsQ2Z0ZGtu amhNci9tRDl2Qlp4dlBGZFYzTGxYdm8KLS0tIDZZWTYxQmVqOEZQaTNOODFGWUhn
ZjhoUHhFbkMvRUhscy9LY05xbXFZVlEKXrt6UPLOprcnsTFX1WCF/pIWXmjiPGEx cXpJL3poT3dpYjZKWTN6dGpOV3kxT2sKozsOz+cSYJdZ0C2L6QCf/VSU9DnOz6ae
7NNbnAl+A9yfheCeiJdWcj5ZBCa6Nx2udVMNjQ7ITMzhIY4BBSicuQ== lqV5MMzSl1Jf8ETpqt+PhvvWz+MLCAkIriT9yf6R29DQifCacB7XOA==
-----END AGE ENCRYPTED FILE----- -----END AGE ENCRYPTED FILE-----
- recipient: age1plkp8td6zzfcavjusmsfrlk54t9vn8jjxm8zaz7cmnr7kzl2nfnsd54hwg - recipient: age1plkp8td6zzfcavjusmsfrlk54t9vn8jjxm8zaz7cmnr7kzl2nfnsd54hwg
enc: | enc: |
-----BEGIN AGE ENCRYPTED FILE----- -----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSA1Y3JyU3JPOWdiamd0cEFV YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBySkpOYWxjQVZRbGtkNXlt
UWRUdUQ5V3ZnQXQ0YURLZ3RiY0toN29vNkhJCnIzYWdkV3puVWQ5Nm5SVGpobllB OTRKTDlrNjNMenU3V0hPeXYyRnhGVU1mMmhNCmhJTi9ZQzB3ekpSR0k1VDFiNExu
OW5WWkVLaE55eUs2OWNDWkZiOGNuZW8KLS0tIHN3eGVST1BRMU1JRWNRZGNJeGdi dW9TQkI3Vy9LOXhQaEExZHMyM25xZlEKLS0tIGRYTkpzbjIvL1FMS2lYYXl4dDVZ
SWJXQ2VMZ3kyWElBSjBKc0tQWmIwMjgKLDakU3iHwVTQBYGR0d2TFFdLdsct49y1 U040akh0Z1ZYVmdjS3k2ZjFRK2VRNGMKqSCnviWARWTkZXeht+sdOYKAxylYYyZK
/S8vydlcx08L0yWHbfamhiYJE3BZbRXZue6z5irBPKEVjGD42aSREw== uXYE3nBaXGosIqmTf6deVqCIY+m0mH/J4UMcbH+faMV4pWmVr2JAxg==
-----END AGE ENCRYPTED FILE----- -----END AGE ENCRYPTED FILE-----
- recipient: age12g6f5fse25tgrwweleh4jls3qs52hey2edh759smulwmk5lnzadslu2cp3 - recipient: age12g6f5fse25tgrwweleh4jls3qs52hey2edh759smulwmk5lnzadslu2cp3
enc: | enc: |
-----BEGIN AGE ENCRYPTED FILE----- -----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBKUklKTzRzdHZiZjdHSmY2 YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBJbUhzZStoUVBHUkZLWlE3
WCs3ZFVodFBGcDdvZWZaaHNnbG9mdFk2WVJjCjUwZjA2Qi9CR1JYdjJ3VFlhZDZR NWNiRkJMdXhUVXRNZTFCMUljVzIxY3BVMFQ4ClpmOGhqeUZiaG1HcU5zdndmWE5y
REEyZStrTjIwbWdCdVU5NWo2Y2hBemsKLS0tIFZmWVh0Z1BFUGRjZExMNlNJSFVJ Ym5OTmoyVVVsb2Ywa3loRTVNZzdlVjQKLS0tIHNEWVV3Mkk2VGVzR3diQW5Ccm1a
VnlWY3U4dW4ycXJCOVVUUlJxYzVONGsKy47vHe+awhJyI/pSUOhvUiOt+jtn+Vwg MVNUYjZCME9rQWFUaWNycEh5THQyTTAKTBnoF76mJ/GoCIq4TsmV+luYbiWnx0+I
Rlm93bJr0GNVWYm521r/I409s3TMitQerweGnl3u7t1FaKX7oI1qBQ== BEISvqsr9gbT0z8kfdo/htPoKHZmnyevZhRhd2AMZdKixYvQMX9sjA==
-----END AGE ENCRYPTED FILE----- -----END AGE ENCRYPTED FILE-----
- recipient: age1tl80n23wq6zxegupwn70ew0yp225ua5v4dk800x7g2w6pvlxz46qk592pa - recipient: age1tl80n23wq6zxegupwn70ew0yp225ua5v4dk800x7g2w6pvlxz46qk592pa
enc: | enc: |
-----BEGIN AGE ENCRYPTED FILE----- -----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBVUE9nVk1SMUp5WW9zYnZs YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBDWEZkODBNOGw2WFdncjJ0
OFBYWmpwZW56S0J0Qk43dmlCWG5RV0RIKzM0CkVNbzI5VSs3VVdaT2xnVEhGdW9h TTVzRHlEa1AzaTF4V2hYR3hFRGg2cnBzYmowCmp3WDJ3bEZoTlFYL2hoZ3hhTVU1
QW02K0ZmeGxsSDZMTGdETjkwd2xVd2MKLS0tIHp2WWQyUFphMmlZaDBZR0JQZ095 WnQyYk03K2xmSk00dS92OHNNZnRIL2cKLS0tIEVrbjY4enJBZzdQMjRCRmwwVlRI
UTVaYXl6SkZUNElUdUlZcU8rditFd3cK0CqMiDzEItfB/T0K8YEncp9HuKWVTy7q OHVOMm9NTGdJbnZ2aXYxdi9OdWpkVE0K4b1Hu6rOHVtfH601aXb/uTGYjNMh6yW/
2LgHBQJi35sdBviEpsHZt7BlHTKanbmB5S9oUexNq+3wUP9e1n5CGA== LetO+HKk+VEzXHntObK2k/4mTl5I0+OP5H8+PR0jdIUZDpr79iEbgQ==
-----END AGE ENCRYPTED FILE----- -----END AGE ENCRYPTED FILE-----
- recipient: age1qy04neuzwpasmvljqrcvhwnf0kz5cpyteze38c8avp0czewskasszv9pyw - recipient: age1qy04neuzwpasmvljqrcvhwnf0kz5cpyteze38c8avp0czewskasszv9pyw
enc: | enc: |
-----BEGIN AGE ENCRYPTED FILE----- -----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSB0VDdRaEVCemd5T0ozSmtp YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBqVE9iMmUwTXE2SHZNdG5P
NllJa0tURVhxTXlZQS8vNUZxbGFQVnhRMDJjClhlaE53WTRpeEtVSGErYUdyZk1h Vi9XQ1Jkc1VDamFlakpkZk45ODZ2YnkwYkVBCnNrbktIdkV4UGltcHBUUHlXbjdx
SloxZGNUdFRDZ1J4bzdneFUyNXdXdkUKLS0tIGdxU3RCUEVmcTdnZnFidkROdXJS Z0QwM3ZKbGI1cDBjL2g2cjdKdElOQjAKLS0tIGxrcTJDa1BWVWcxUS80MmxIMWZH
dVdQUUdVUUhpVVh6SytRV1V0RHV6cDQKagJA6w43n8yh119PrJltPPh/EbHIKjfH YjBRMDZJZWlmN1FNaXV5c04yVWtleE0K+nGNyFzqSotFP7My/kUnAgxXGu/ji50K
G8RfgXBrf5iWdWsyD8haDL6WsjdbVQsbPCz+ucULfnZ1Dn8A/3yQUQ== OGVLYgNvU48rCGck3r9ZrKY1HpQdAY8UMQXECsuO4HgdirNjiZ97Zg==
-----END AGE ENCRYPTED FILE----- -----END AGE ENCRYPTED FILE-----
- recipient: age18fgn6j2vwwswqcpv9xpcehq8mrf9zs2sglwkamp3tzwx8d9jq9jsrskrk9 - recipient: age18fgn6j2vwwswqcpv9xpcehq8mrf9zs2sglwkamp3tzwx8d9jq9jsrskrk9
enc: | enc: |
-----BEGIN AGE ENCRYPTED FILE----- -----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBIbGgxRG5sZHFPZm5OTnZw YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSA4ZXZud0dqb0dkQ0E3NnE4
L21JUnFDOTNZUitYNHJad3FnYkk2aDlQRzM0CnI1NXZDcEtQdjBWaVhJMElIOEZU SXB0ZENjQk1mb1BHU2R1bW0waDhTYy9OZldVCjVnRTV5d1c3Q2NzcEVRQ3BoL09I
KzFjT0p4OVpzUFlnR1Q5cm5SVnhGaEUKLS0tIFliVVQ4VFg4QVJxazdyZG9PbE9M T1RPQ3hHT3Y2NFNzWG9EdGM2STR2STgKLS0tIHBvL3RhREFNTVdwUGk3S1B4NWJL
M3NzZFRKMFkxZHZ1Rmh0VWFWMDZTQlEKxKVEvnnV56t/RSvP94TgjW1Do68Tn2N2 TnZpblF1SDdGRlVXM0dEdFAzT1FEMUUK6L8gTv5gt6++A3B7PHyWl+xtBUc8bC6G
tJMI8VIp7rs5vxaToois6CMuhVONsl1CBozAEV8pqS5O830RGa1eog== 53xoJvyyBpaov3HgUAdrN9VHubfEJmrBGgN7DngGgwYPtlhV87M7/w==
-----END AGE ENCRYPTED FILE----- -----END AGE ENCRYPTED FILE-----
- recipient: age1hm2hsfgjezpsc3k0y5w5feq9t8vl3seq04qjhgt6ztd6403wfvpsgxu09m - recipient: age1hm2hsfgjezpsc3k0y5w5feq9t8vl3seq04qjhgt6ztd6403wfvpsgxu09m
enc: | enc: |
-----BEGIN AGE ENCRYPTED FILE----- -----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBta3VwOGQrSHdENGZLZUpq YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBlaXY1VmtDejcwTmUxRVZT
SGw0Z2YwYm8vZ0h4Y01UclIrQlRVNWVJaHlRCmFMQkp1aGc5R1JrMVdPNmwvbkYv YURhMkVPaHNvb0sxT0FYL0pvN3hqclNNcXdRCmxWV3FGeDZTM1VVMVRyalpkVnFJ
NkxOc3pYY2E5dVVFejZJaitSb0hRUVUKLS0tIGFzclh3VXhBc3NYQjZlUmNUSk10 OGU3Wk9wVVAvejVTdjc1MENPcy9Qc1kKLS0tIGpJQXhZVzV3REc2SFlFSXg0dUo5
S2xqMEpBcEpaOHdBNUtvOWxsVUhLLzQKGT3grtnocBkaCKXbtH7cZu/ZP9MKsAjt bjRBaGtJdUFmVUkxeGgwbGYwWjRnNEkKYwzwZ9oOo+C6XD57rkUTO6QADZKzYfSF
ZNmS1GahpBx2lVEbfJNLfwId+IJ6kOIyHj42g9yIQFfKTPKE32Ht8A== cFJ7fX0NyZbzxLncyofWa+dlLWLZ3KohIP0doAFngRm+RVsUEVqY5A==
-----END AGE ENCRYPTED FILE----- -----END AGE ENCRYPTED FILE-----
lastmodified: "2024-07-18T13:08:42Z" - recipient: age1hnhuzj96ktkhpyygvmz0x9h8mfvssz7ss6emmukags644mdhf4msajk93r
mac: ENC[AES256_GCM,data:a/uHyw9V/SMIePV9nPf+wJgPg+YDYLJGYy7NMLBrBgCXtBWHHonSNjzdmtjix1bW2y+cU0gMqodrtqR1cJGBmXr4NRY7NJqgLWE9rEdYfG7BnfqsWmvAaTIrSs7QMZWkEic7ys/bXoA5BZoau3olhVqIO2A/iyBtoMU9Hv7hPlo=,iv:gaqSCUbN7cxWPNrFPDTl7xNxpOZL6GY/swD/MDCiRqk=,tag:Oz0f/DyD3KGV/9Rprj/1Xw==,type:str] 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.8.1 version: 3.9.0
@@ -99,6 +99,23 @@ backend:
FRONTEND_SILENCE_LIVEKIT_DEBUG: False FRONTEND_SILENCE_LIVEKIT_DEBUG: False
FRONTEND_ANALYTICS: "{'id': 'phc_RPYko028Oqtj0c9exLIWwrlrjLxSdxT0ntW0Lam4iom', 'host': 'https://product.visio.numerique.gouv.fr'}" FRONTEND_ANALYTICS: "{'id': 'phc_RPYko028Oqtj0c9exLIWwrlrjLxSdxT0ntW0Lam4iom', 'host': 'https://product.visio.numerique.gouv.fr'}"
FRONTEND_SUPPORT: "{'id': '58ea6697-8eba-4492-bc59-ad6562585041'}" 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:
+37 -2
View File
@@ -1,7 +1,7 @@
image: image:
repository: lasuite/meet-backend repository: lasuite/meet-backend
pullPolicy: Always pullPolicy: Always
tag: "main" tag: "v-hackathon"
backend: backend:
migrateJobAnnotations: migrateJobAnnotations:
@@ -97,6 +97,41 @@ backend:
ALLOW_UNREGISTERED_ROOMS: False ALLOW_UNREGISTERED_ROOMS: False
FRONTEND_ANALYTICS: "{'id': 'phc_RPYko028Oqtj0c9exLIWwrlrjLxSdxT0ntW0Lam4iom', 'host': 'https://product.visio-staging.beta.numerique.gouv.fr'}" FRONTEND_ANALYTICS: "{'id': 'phc_RPYko028Oqtj0c9exLIWwrlrjLxSdxT0ntW0Lam4iom', 'host': 'https://product.visio-staging.beta.numerique.gouv.fr'}"
FRONTEND_SUPPORT: "{'id': '58ea6697-8eba-4492-bc59-ad6562585041'}" 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:
@@ -110,7 +145,7 @@ frontend:
image: image:
repository: lasuite/meet-frontend repository: lasuite/meet-frontend
pullPolicy: Always pullPolicy: Always
tag: "main" tag: "v-hackathon"
ingress: ingress:
enabled: true enabled: true
+8
View File
@@ -0,0 +1,8 @@
apiVersion: core.libre.sh/v1alpha1
kind: Bucket
metadata:
name: meet-media-storage
namespace: {{ .Release.Namespace | quote }}
spec:
provider: data
versioned: true
+13
View File
@@ -15,3 +15,16 @@ stringData:
OIDC_RP_CLIENT_SECRET: {{ .Values.oidc.clientSecret }} OIDC_RP_CLIENT_SECRET: {{ .Values.oidc.clientSecret }}
LIVEKIT_API_SECRET: {{ .Values.livekitApi.secret }} LIVEKIT_API_SECRET: {{ .Values.livekitApi.secret }}
LIVEKIT_API_KEY: {{ .Values.livekitApi.key }} LIVEKIT_API_KEY: {{ .Values.livekitApi.key }}
{{- if .Values.openaiApiKey }}
OPENAI_API_KEY: {{ .Values.openaiApiKey }}
{{- end }}
{{- if .Values.minioAccessKey }}
MINIO_ACCESS_KEY: {{ .Values.minioAccessKey }}
{{- end }}
{{- if .Values.minioSecretKey }}
MINIO_SECRET_KEY: {{ .Values.minioSecretKey }}
{{- end }}
{{- if .Values.minioUrl }}
MINIO_URL: {{ .Values.minioUrl }}
{{- end }}
+19
View File
@@ -0,0 +1,19 @@
<mjml>
<mj-include path="./partial/header.mjml" />
<mj-body mj-class="bg--blue-100">
<mj-wrapper css-class="wrapper" padding="0 40px 40px 40px">
<mj-section mj-class="bg--white-100" padding="30px 20px 60px 20px">
<mj-column>
<mj-text font-size="14px">
<p>Cher utilisateur,</p>
<p>La réunion <strong>{{room}}</strong> a été transcrite et résumée avec succès.</p>
</mj-text>
<mj-button href="{{link}}" font-size="14px" background-color="#346DB7" color="white">
Obtenez votre résumé
</mj-button>
</mj-column>
</mj-section>
</mj-wrapper>
</mj-body>
<mj-include path="./partial/footer.mjml" />
</mjml>
+1 -1
View File
@@ -14,7 +14,7 @@
"build": "npm run build-mjml-to-html && npm run build-html-to-plain-text" "build": "npm run build-mjml-to-html && npm run build-html-to-plain-text"
}, },
"volta": { "volta": {
"node": "20.17.0" "node": "20.18.0"
}, },
"repository": "https://github.com/numerique-gouv/meet", "repository": "https://github.com/numerique-gouv/meet",
"author": "DINUM", "author": "DINUM",