Compare commits

...

36 Commits

Author SHA1 Message Date
lebaudantoine 223cee3828 🔖(minor) bump release to 0.1.4 2024-08-12 23:32:19 +02:00
lebaudantoine 101be503bf 🚸(frontend) fix widget state management
The current design is inefficient, using two separate approaches to manage
the app state and determine which widget is open. This inconsistency affects
the user experience and overall design quality.

Soon, only one container will be open/close, and its content will be toggle,
using an extensible system.

As a quick fix, I’ve added basic logic to ensure that opening one widget
automatically closes any other open widgets, such as the chat or participants
widget. This is a temporary measure to fix state management until a
more robust solution can be implemented.
2024-08-12 23:16:01 +02:00
lebaudantoine 47e2d85471 ♻️(frontend) refactor Chat toggle
This commit refactor the default chat toggle to enhance few details:

- styling (e.g. button's size, icon's style, etc)
- accessibility (e.g. tooltip, aria label, keyboard controls)
- notifications (enhanced contrast on the red dot, feedback from Arnaud)
2024-08-12 23:16:01 +02:00
lebaudantoine 16321b3cb0 🚧(frontend) introduce a participants list
I faced few challenge while using LiveKit hook, please refer to my commits.
I'll work on a PR on their repo to get it fixed. Duplicating their sources
add overhead.

This commit introduce the most minimal participants list possible. More
controls or information will be added in the upcomming commits.
Responsiveness and accessibility should be functional.

Soon, any right side panel will share the same container to avoid visual
glichts.
2024-08-12 23:16:01 +02:00
lebaudantoine 144cb56cda 💄(frontend) add spacing around dialog close icon
Feel weird having the button border touching the Dialog's one.
Add a bit of spacing to fix this visual issue.
2024-08-12 23:16:01 +02:00
lebaudantoine e6a15e36b4 (frontend) create a control button for participants list
Introduced a button to manage the visibility of the participants list.

Encountered refresh issues with the room context where the participant count
in the room metadata does not update in real-time, causing discrepancies
between the actual number of participants and the displayed count. To address
this, I utilized the participants hook, which updates more frequently and
ensures consistency.
2024-08-12 23:16:01 +02:00
lebaudantoine 3541af5992 (frontend) extend button primitive with a toggle variant
Encountered issues extending the current button due to the complexity of the
Button and Link props construction. I’ll need to redesign the component to
enhance its extensibility.

The legacy style adheres to the previous interface but suffers from poor
color
contrast. Hex codes were used to match the selected toggle color,
resulting
in a difficult color system to maintain. This approach needs refactoring
for better color handling. Legacy styles will soon disappear.

Efforts were made to minimize code for handling the toggle functionality.
Future work should focus on refactoring the button to align with the field
approach for improved developer experience and props validation.
2024-08-12 23:16:01 +02:00
lebaudantoine 68f0ea3639 🩹(frontend) prevent controls shift when opening chat
In LiveKit, opening the chat causes the entire page layout, including
participant tiles and controls, to shift left. This disrupts the user
experience, as the chat toggle button moves, requiring users to reposition
their cursor to close the chat.

This behavior also prevents effective organization of the control bar,
especially when trying to maintain a centered section for main controls and
a fixed section for quick-access buttons on the left or right bottom corners.

This fix temporarily locks the controls and participant list in place when
the chat is toggled. The styles will be refactored during the upcoming
redesign.
2024-08-12 23:16:01 +02:00
lebaudantoine 925eb92c60 🗃️(database) add missing migrations for user's language
Addressed missing migrations for the user's language field,
ensuring recent updates are correctly applied.
2024-08-09 17:25:09 +02:00
lebaudantoine d965ee7060 🔒️(helm) allow server host and whitelist pod IP for health checks
Updated Django's ALLOWED_HOSTS setting from '*' to the specific host of the
server. Setting ALLOWED_HOSTS to '*' is a security risk as it allows any host
to access the application, potentially exposing it to malicious attacks.
Restricting ALLOWED_HOSTS to the server's host ensures only legitimate
requests are processed.

In a Kubernetes environment, we also needed to whitelist the pod's IP address
to allow health checks to pass. This ensures that Kubernetes liveness and
readiness probes can access the application to verify its health.
2024-08-09 17:25:09 +02:00
lebaudantoine aef85bb1ab 🏗️(bin) merge duplicated folders scripts and bin
Few scripts were duplicated between the scripts and the bin folders.
Reorganize the scripts in a common folder, and align filenames to
follow the same rule.
2024-08-09 17:25:09 +02:00
lebaudantoine 8f59b08088 🩺(helm) update liveness and readiness probes from 10s to 30s
Updated the liveness and readiness probes interval from every 10 seconds to
every 30 seconds. This change reduces the load on the server by decreasing
the frequency of health checks.

Given the current stability of the application, a 30-second interval is
sufficient to ensure that the application remains responsive and healthy.
2024-08-09 17:25:09 +02:00
lebaudantoine 366998b3d6 🔇(backend) silence security warnings for reverse proxy setup
Silenced certain Django security warnings because the application is served
behind a reverse proxy. These warnings are not applicable in our deployment
context, where the reverse proxy handles these security concerns.

This change ensures relevant security measures are appropriately managed
while avoiding unnecessary warnings. Any question? asked @rouja.

/!\ actually, this commit is not working, and should be fixed.
2024-08-09 17:25:09 +02:00
lebaudantoine c8ad0ab24f 🔥(terraform) remove legacy terraform and OpenStack references
Some outdated references to Terraform and OpenStack were missed during
the project quickstart. These are legacy elements inherited from OpenFun.

This commit cleans up the codebase.
2024-08-09 17:25:09 +02:00
lebaudantoine d2bbcb0f51 🔇(backend) fix E010 Django warning logs
Room model uses a default value for its configuration.
However, I used a wrong default value, it should be a callable.

Align the code with Magnify.
2024-08-09 17:25:09 +02:00
lebaudantoine 40457bd68a 🐛(frontend) avoid unnecessary refetches of room's data
LiveKit access tokens are valid for 6 hours after generation. Without a
`staleTime` set, room data becomes stale immediately (0 milliseconds),
causing unnecessary refetches, particularly when users switch focus back
to the call window.

This issue led to an excessive number of "get room" events, as observed
in the analytics. For example, during a meeting, if a user switches tabs
and then returns, the room data would be refetched despite the access
token still being valid.

By adding a `staleTime` aligned with the token's validity, we avoid
unnecessary network requests.
2024-08-08 14:38:34 +02:00
lebaudantoine c21c5f604b 🩹(frontend) clear router state on page reload
(aligned with Google Meet or Jitsi ux)

This ensures participants join a room with a fresh access token.

Some participants information might be altered after the access token
was generated (e.g. their name).

I wanted the initial data to be passed only once, when you navigate
to the room from the create button, but if you reload the page, then
a new access token will be fetched, and the pre-join screen displayed.
2024-08-08 14:37:40 +02:00
lebaudantoine 66350b8fb5 🐛(frontend) pass participant's name while creating a room
Fixed an issue where users' chosen usernames were not
being included when creating a room.
2024-08-08 14:34:18 +02:00
lebaudantoine 378cc3a651 (frontend) persist local participant name on updates
Utilized local storage to persist the updated name. After careful
consideration, persisting all user choices in the backend was deemed overly
complex and unnecessary for the value it would provide.

Opted for a single responsibility approach by leveraging local storage to manage
user choices, aligning with LiveKit's default implementation.
2024-08-08 14:34:18 +02:00
lebaudantoine 28f43fb2c0 ♻️(backend) refactor LiveKit access token handling
Switched to using query parameters instead of GET requests, enabling the
inclusion of additional parameters when calling the create endpoint via POST.

Simplified the access token generation process by removing redundant calls to
`with_identity` and consolidating token generation steps. This streamlines the
method flow by preparing all necessary data before generating the access token.
2024-08-08 14:34:18 +02:00
lebaudantoine 37eea16a50 (frontend) allow local participant to update her name
The user name is initially set by the backend when generating the LiveKit access
token. By default, if the frontend has not provided a username, the backend uses
the user's email address. This approach isn't ideal, as some users prefer using
their first name.

This update allows local participant to change their username in real-time
during a session. However, these changes are not yet persisted for
future meetings. This persistence feature will be added in upcoming commits.
2024-08-08 14:34:18 +02:00
lebaudantoine fe8ed43aae (backend) allow LiveKit users to update their own metadata
This is required, if users need to update their names, or participant
information.
2024-08-08 14:34:18 +02:00
lebaudantoine 671aa68804 ♻️(frontend) restore User Information in Settings Dialog
Reintroduced user information in the settings dialog. This change allows
users to see the account they are logged into, which is important for
account management. An additional feature to update usernames will be
added soon. Apologies to @manuhabitela for the oversight.
2024-08-08 14:34:18 +02:00
lebaudantoine 6449aaf7ea ♻️(frontend) remove header from video conference
Removed the header when inside a room to maximize space for the camera
gallery. Settings are now accessible via the options menu.

It aligns the design with common video conference tools as meet,
or jitsi. @manuhabitela was aligned with this change.
2024-08-08 14:34:18 +02:00
lebaudantoine f8fff3dbdf ♻️(frontend) allow opening Settings dialog in a controlled way
I am not hundred percent sure it respects @manuhabitela's guidelines,
but for the options menu, I would need to trigger a dialog in a controlled way.

I tried to have the minimal impact on the existing settings dialog.
2024-08-08 14:34:18 +02:00
lebaudantoine 1715ec10dd (frontend) introduce a menu with more actions
Replaced the existing settings button with a new `OptionsButton` component.
This new component integrates a menu for more actions, providing an
extensible and accessible interface.

Refactoring will follow to enhance code maintainability.

A legacy style was introduced to keep visual coherence with the other controls
from the controls bar. It's temporary, until we redesign the whole bar.
It doesn't match pixel-perfect the legacy styles.

A new public Tchap chan was created. All users, and not only beta users,
can now join it using the link in the menu, and reach us.
2024-08-08 14:34:18 +02:00
lebaudantoine 8c7aed4b00 ♻️(frontend) simplify the controls component
As we duplicated the code, we don't need anymore to configure
the available controls from the video conference.
2024-08-08 14:34:18 +02:00
lebaudantoine 217b19e42a 🌐(frontend) internationalize video conference controls
Localized video conference controls to match the selected language.

Previously, controls were always in English, causing confusion for French users.
A full refactor of the controls is planned soon.
2024-08-08 14:34:18 +02:00
lebaudantoine bd26a0abc1 💩(frontend) duplicate Controls bar
Same approach as the previous commit. Few elements were not exposed
by the LiveKit package, and I had to duplicate them. Let's see if
we can asap get rid off all this complexity.
2024-08-08 14:34:18 +02:00
lebaudantoine abb708aa49 💩(frontend) duplicate VideoConference component
Basically, duplicate LiveKit code to start iterating on their
components, not sure wether it's the optimal strategy, but at least
we will be more agile, shipping small features which are lacking.
2024-08-08 14:34:18 +02:00
lebaudantoine 5d35161ae3 👷(frontend) add linting and formatting checks for frontend
Added CI job to run linting and formatting checks in the frontend
codebase. Please note, we should cache frontend dependencies,
to avoid re-installing them. Future improvement!
2024-08-06 12:25:22 +02:00
lebaudantoine 0b6f58bf9c 🚨(frontend) run Prettier on the codebase
Ran Prettier on the entire codebase to fix formatting issues. My IDE was
previously misconfigured, causing most of these errors. The IDE configuration
has been corrected.
2024-08-06 12:25:22 +02:00
lebaudantoine 79519fef26 🔧(frontend) configure Prettier and ESLint
Set up Prettier with minimal configuration. Installed eslint-config-prettier to
prevent conflicts between ESLint and Prettier.
2024-08-06 12:25:22 +02:00
lebaudantoine d7b1fbaf28 ♻️(mail) refactor mail to use npm instead of yarn
Following @manuhabitela's recommendation, we've decided to switch
from Yarn to npm for managing our Node.js dependencies. This update
aligns all remaining parts of the codebase that were still using
Yarn to now utilize npm.
2024-08-06 11:16:54 +02:00
renovate[bot] 26bc67d1b4 ⬆️(dependencies) update boto3 to v1.34.154 2024-08-06 10:42:41 +02:00
lebaudantoine b783a8bac6 📝(doc) add an example for the frontend image
I added an example in the documentation to ensure we remember to upgrade the
frontend image during the release process. This will help prevent any issues
related to outdated images when deploying new versions.

(I did this mistake while releasing)
2024-08-05 23:14:06 +02:00
66 changed files with 3278 additions and 1512 deletions
+15
View File
@@ -160,6 +160,21 @@ jobs:
- name: Run tests
run: ~/.local/bin/pytest -n 2
lint-front:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Install dependencies
run: cd src/frontend/ && npm ci
- name: Check linting
run: cd src/frontend/ && npm run lint
- name: Check format
run: cd src/frontend/ && npm run check
i18n-crowdin:
runs-on: ubuntu-latest
steps:
+5 -5
View File
@@ -48,7 +48,7 @@ WAIT_DB = @$(COMPOSE_RUN) dockerize -wait tcp://$(DB_HOST):$(DB_PORT
# -- Backend
MANAGE = $(COMPOSE_RUN_APP) python manage.py
MAIL_YARN = $(COMPOSE_RUN) -w /app/src/mail node yarn # FIXME : use npm
MAIL_NPM = $(COMPOSE_RUN) -w /app/src/mail node npm
# -- Frontend
PATH_FRONT = ./src/frontend
@@ -259,19 +259,19 @@ i18n-generate-and-upload: \
# -- Mail generator
mails-build: ## Convert mjml files to html and text
@$(MAIL_YARN) build
@$(MAIL_NPM) run build
.PHONY: mails-build
mails-build-html-to-plain-text: ## Convert html files to text
@$(MAIL_YARN) build-html-to-plain-text
@$(MAIL_NPM) run build-html-to-plain-text
.PHONY: mails-build-html-to-plain-text
mails-build-mjml-to-html: ## Convert mjml files to html and text
@$(MAIL_YARN) build-mjml-to-html
@$(MAIL_NPM) run build-mjml-to-html
.PHONY: mails-build-mjml-to-html
mails-install: ## install the mail generator
@$(MAIL_YARN) install
@$(MAIL_NPM) install
.PHONY: mails-install
-64
View File
@@ -5,7 +5,6 @@ set -eo pipefail
REPO_DIR="$(cd "$( dirname "${BASH_SOURCE[0]}" )/.." && pwd)"
UNSET_USER=0
TERRAFORM_DIRECTORY="./env.d/terraform"
COMPOSE_FILE="${REPO_DIR}/docker-compose.yml"
COMPOSE_PROJECT="meet"
@@ -92,66 +91,3 @@ function _dc_exec() {
function _django_manage() {
_dc_run "app-dev" python manage.py "$@"
}
# _set_openstack_project: select an OpenStack project from the openrc files defined in the
# terraform directory.
#
# usage: _set_openstack_project
#
# If necessary the script will prompt the user to choose a project from those available
function _set_openstack_project() {
declare prompt
declare -a projects
declare -i default=1
declare -i choice=0
declare -i n_projects
# List projects by looking in the "./env.d/terraform" directory
# and store them in an array
read -r -a projects <<< "$(
find "${TERRAFORM_DIRECTORY}" -maxdepth 1 -mindepth 1 -type d |
sed 's|'"${TERRAFORM_DIRECTORY}\/"'||' |
xargs
)"
nb_projects=${#projects[@]}
if [[ ${nb_projects} -le 0 ]]; then
echo "There are no OpenStack projects defined..." >&2
echo "To add one, create a subdirectory in \"${TERRAFORM_DIRECTORY}\" with the name" \
"of your project and copy your \"openrc.sh\" file into it." >&2
exit 10
fi
if [[ ${nb_projects} -gt 1 ]]; then
prompt="Select an OpenStack project to target:\\n"
for (( i=0; i<nb_projects; i++ )); do
prompt+="[$((i+1))] ${projects[$i]}"
if [[ $((i+1)) -eq ${default} ]]; then
prompt+=" (default)"
fi
prompt+="\\n"
done
prompt+="If your OpenStack project is not listed, add it to the \"env.d/terraform\" directory.\\n"
prompt+="Your choice: "
read -r -p "$(echo -e "${prompt}")" choice
if [[ ${choice} -gt nb_projects ]]; then
(>&2 echo "Invalid choice ${choice} (should be <= ${nb_projects})")
exit 11
fi
if [[ ${choice} -le 0 ]]; then
choice=${default}
fi
fi
project=${projects[$((choice-1))]}
# Check that the openrc.sh file exists for this project
if [ ! -f "${TERRAFORM_DIRECTORY}/${project}/openrc.sh" ]; then
(>&2 echo "Missing \"openrc.sh\" file in \"${TERRAFORM_DIRECTORY}/${project}\". Check documentation.")
exit 12
fi
echo "${project}"
}
-25
View File
@@ -1,25 +0,0 @@
#!/usr/bin/env bash
set -eo pipefail
source "$(dirname "${BASH_SOURCE[0]}")/_config.sh"
project=$(_set_openstack_project)
echo "Using \"${project}\" project..."
source "${TERRAFORM_DIRECTORY}/${project}/openrc.sh"
# Run Terraform commands in the Hashicorp docker container via docker compose
# shellcheck disable=SC2068
DOCKER_USER="$(id -u):$(id -g)" \
PROJECT="${project}" \
docker compose run --rm \
-e OS_AUTH_URL \
-e OS_IDENTITY_API_VERSION \
-e OS_USER_DOMAIN_NAME \
-e OS_PROJECT_DOMAIN_NAME \
-e OS_TENANT_ID \
-e OS_TENANT_NAME \
-e OS_USERNAME \
-e OS_PASSWORD \
-e OS_REGION_NAME \
terraform-state "$@"
-26
View File
@@ -1,26 +0,0 @@
#!/usr/bin/env bash
set -eo pipefail
source "$(dirname "${BASH_SOURCE[0]}")/_config.sh"
project=$(_set_openstack_project)
echo "Using \"${project}\" project..."
source "${TERRAFORM_DIRECTORY}/${project}/openrc.sh"
# Run Terraform commands in the Hashicorp docker container via docker compose
# shellcheck disable=SC2068
DOCKER_USER="$(id -u):$(id -g)" \
PROJECT="${project}" \
docker compose run --rm \
-e OS_AUTH_URL \
-e OS_IDENTITY_API_VERSION \
-e OS_USER_DOMAIN_NAME \
-e OS_PROJECT_DOMAIN_NAME \
-e OS_TENANT_ID \
-e OS_TENANT_NAME \
-e OS_USERNAME \
-e OS_PASSWORD \
-e OS_REGION_NAME \
-e TF_VAR_user_name \
terraform "$@"
+19 -11
View File
@@ -3,20 +3,28 @@
Whenever we are cooking a new release (e.g. `4.18.1`) we should follow a standard procedure described below:
1. Create a new branch named: `release/4.18.1`.
2. Bump the release number for backend project, frontend projects, and Helm files:
2. Bump the release number for backend project, frontend projects, and Helm files:
- for backend, update the version number by hand in `pyproject.toml`,
- for each frontend projects (`src/frontend`, `src/mail`), run `npm version 4.18.1` in their directory. This will update both their `package.json` and `package-lock.json` for you,
- for Helm, update Docker image tag in files located at `src/helm/env.d` for both `preprod` and `production` environments:
- for backend, update the version number by hand in `pyproject.toml`,
- for each frontend projects (`src/frontend`, `src/mail`), run `npm version 4.18.1` in their directory. This will update both their `package.json` and `package-lock.json` for you,
- for Helm, update Docker image tag in files located at `src/helm/env.d` for both `preprod` and `production` environments:
```yaml
image:
repository: lasuite/meet-backend
pullPolicy: Always
tag: "v4.18.1" # Replace with your new version number, without forgetting the "v" prefix
```
```yaml
image:
repository: lasuite/meet-backend
pullPolicy: Always
tag: "v4.18.1" # Replace with your new version number, without forgetting the "v" prefix
...
frontend:
image:
repository: lasuite/meet-frontend
pullPolicy: Always
tag: "v4.18.1" # Replace with your new version number, without forgetting the "v" prefix
```
The new images don't exist _yet_: they will be created automatically later in the process.
The new images don't exist _yet_: they will be created automatically later in the process.
3. ~~Update the project's `Changelog` following the [keepachangelog](https://keepachangelog.com/en/0.3.0/) recommendations~~ _we don't keep a changelog yet for now as the project is still in its infancy. Soon™!_
4. Commit your changes with the following format: the 🔖 release emoji, the type of release (patch/minor/patch) and the release version:
-3
View File
@@ -1,3 +0,0 @@
#!/bin/bash
find . -name "*.enc.*" -exec sops updatekeys -y {} \;
+1 -1
View File
@@ -123,7 +123,7 @@ class RoomSerializer(serializers.ModelSerializer):
if role is not None or instance.is_public:
slug = f"{instance.id!s}".replace("-", "")
username = request.GET.get("username", None)
username = request.query_params.get("username", None)
output["livekit"] = {
"url": settings.LIVEKIT_CONFIGURATION["url"],
@@ -0,0 +1,18 @@
# Generated by Django 5.0.7 on 2024-08-07 14:38
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0002_create_pg_trgm_extension'),
]
operations = [
migrations.AlterField(
model_name='room',
name='configuration',
field=models.JSONField(blank=True, default=dict, help_text='Values for Visio parameters to configure the room.', verbose_name='Visio room configuration'),
)
]
@@ -0,0 +1,18 @@
# Generated by Django 5.0.7 on 2024-08-07 14:39
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0003_alter_room_configuration'),
]
operations = [
migrations.AlterField(
model_name='user',
name='language',
field=models.CharField(choices="(('en-us', 'English'), ('fr-fr', 'French'))", default='en-us', help_text='The language in which the user wants to see the interface.', max_length=10, verbose_name='language'),
),
]
+1 -1
View File
@@ -296,7 +296,7 @@ class Room(Resource):
configuration = models.JSONField(
blank=True,
default={},
default=dict,
verbose_name=_("Visio room configuration"),
help_text=_("Values for Visio parameters to configure the room."),
)
+13 -10
View File
@@ -11,7 +11,7 @@ from livekit.api import AccessToken, VideoGrants
def generate_token(room: str, user, username: Optional[str] = None) -> str:
"""Generate a Livekit access token for a user in a specific room.
"""Generate a LiveKit access token for a user in a specific room.
Args:
room (str): The name of the room.
@@ -22,10 +22,10 @@ def generate_token(room: str, user, username: Optional[str] = None) -> str:
Returns:
str: The LiveKit JWT access token.
"""
video_grants = VideoGrants(
room=room,
room_join=True,
can_update_own_metadata=True,
can_publish_sources=[
"camera",
"microphone",
@@ -34,18 +34,21 @@ def generate_token(room: str, user, username: Optional[str] = None) -> str:
],
)
token = AccessToken(
api_key=settings.LIVEKIT_CONFIGURATION["api_key"],
api_secret=settings.LIVEKIT_CONFIGURATION["api_secret"],
).with_grants(video_grants)
if user.is_anonymous:
token.with_identity(str(uuid4()))
identity = str(uuid4())
default_username = "Anonymous"
else:
token.with_identity(user.sub)
identity = str(user.sub)
default_username = str(user)
token.with_name(username or default_username)
token = (
AccessToken(
api_key=settings.LIVEKIT_CONFIGURATION["api_key"],
api_secret=settings.LIVEKIT_CONFIGURATION["api_secret"],
)
.with_grants(video_grants)
.with_identity(identity)
.with_name(username or default_username)
)
return token.to_jwt()
+7 -1
View File
@@ -12,6 +12,7 @@ https://docs.djangoproject.com/en/3.1/ref/settings/
import json
import os
from socket import gethostbyname, gethostname
from django.utils.translation import gettext_lazy as _
@@ -71,6 +72,7 @@ class Base(Configuration):
# Security
ALLOWED_HOSTS = values.ListValue([])
SECRET_KEY = values.Value(None)
SILENCED_SYSTEM_CHECKS = values.ListValue([])
# Application definition
ROOT_URLCONF = "meet.urls"
@@ -513,7 +515,11 @@ class Production(Base):
"""
# Security
ALLOWED_HOSTS = values.ListValue(None)
ALLOWED_HOSTS = [
*values.ListValue([], environ_name="ALLOWED_HOSTS"),
gethostbyname(gethostname()),
]
CSRF_TRUSTED_ORIGINS = values.ListValue([])
SECURE_BROWSER_XSS_FILTER = True
SECURE_CONTENT_TYPE_NOSNIFF = True
+2 -2
View File
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "meet"
version = "0.1.3"
version = "0.1.4"
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
classifiers = [
"Development Status :: 5 - Production/Stable",
@@ -25,7 +25,7 @@ license = { file = "LICENSE" }
readme = "README.md"
requires-python = ">=3.10"
dependencies = [
"boto3==1.34.153",
"boto3==1.34.154",
"Brotli==1.1.0",
"celery[redis]==5.4.0",
"django-configurations==2.5.1",
+1
View File
@@ -7,6 +7,7 @@ module.exports = {
'plugin:react-hooks/recommended',
'plugin:@tanstack/eslint-plugin-query/recommended',
'plugin:jsx-a11y/recommended',
'prettier'
],
ignorePatterns: ['dist', '.eslintrc.cjs', 'styled-system'],
parser: '@typescript-eslint/parser',
+35 -5
View File
@@ -1,12 +1,12 @@
{
"name": "meet",
"version": "0.1.3",
"version": "0.1.4",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "meet",
"version": "0.1.3",
"version": "0.1.4",
"dependencies": {
"@livekit/components-react": "2.3.3",
"@livekit/components-styles": "1.0.12",
@@ -37,10 +37,12 @@
"@typescript-eslint/parser": "7.13.1",
"@vitejs/plugin-react": "4.3.1",
"eslint": "8.57.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-jsx-a11y": "6.9.0",
"eslint-plugin-react-hooks": "4.6.2",
"eslint-plugin-react-refresh": "0.4.7",
"postcss": "8.4.39",
"prettier": "3.3.3",
"typescript": "5.5.2",
"vite": "5.3.1",
"vite-tsconfig-paths": "4.3.2"
@@ -452,6 +454,7 @@
},
"node_modules/@clack/prompts/node_modules/is-unicode-supported": {
"version": "1.3.0",
"dev": true,
"inBundle": true,
"license": "MIT",
"engines": {
@@ -1588,6 +1591,21 @@
"node": "^10 || ^12 || >=14"
}
},
"node_modules/@pandacss/node/node_modules/prettier": {
"version": "3.2.5",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.2.5.tgz",
"integrity": "sha512-3/GWa9aOC0YeD7LUfvOG2NiDyhOWRvt1k+rcKhOuYnMY24iiCphgneUfJDyFXd6rZCAnuLBv6UeAULtrhT/F4A==",
"dev": true,
"bin": {
"prettier": "bin/prettier.cjs"
},
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/prettier/prettier?sponsor=1"
}
},
"node_modules/@pandacss/parser": {
"version": "0.41.0",
"resolved": "https://registry.npmjs.org/@pandacss/parser/-/parser-0.41.0.tgz",
@@ -5700,6 +5718,18 @@
"url": "https://opencollective.com/eslint"
}
},
"node_modules/eslint-config-prettier": {
"version": "9.1.0",
"resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-9.1.0.tgz",
"integrity": "sha512-NSWl5BFQWEPi1j4TjVNItzYV7dZXZ+wP6I6ZhrBGpChQhZRUaElihE9uRRkcbRnNb76UMKDF3r+WTmNcGPKsqw==",
"dev": true,
"bin": {
"eslint-config-prettier": "bin/cli.js"
},
"peerDependencies": {
"eslint": ">=7.0.0"
}
},
"node_modules/eslint-plugin-jsx-a11y": {
"version": "6.9.0",
"resolved": "https://registry.npmjs.org/eslint-plugin-jsx-a11y/-/eslint-plugin-jsx-a11y-6.9.0.tgz",
@@ -8919,9 +8949,9 @@
}
},
"node_modules/prettier": {
"version": "3.2.5",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.2.5.tgz",
"integrity": "sha512-3/GWa9aOC0YeD7LUfvOG2NiDyhOWRvt1k+rcKhOuYnMY24iiCphgneUfJDyFXd6rZCAnuLBv6UeAULtrhT/F4A==",
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.3.3.tgz",
"integrity": "sha512-i2tDNA0O5IrMO757lfrdQZCc2jPNDVntV0m/+4whiDfWaTKfMNgR7Qz0NAeGz/nRqF4m5/6CLzbP4/liHt12Ew==",
"dev": true,
"bin": {
"prettier": "bin/prettier.cjs"
+6 -2
View File
@@ -1,14 +1,16 @@
{
"name": "meet",
"private": true,
"version": "0.1.3",
"version": "0.1.4",
"type": "module",
"scripts": {
"dev": "panda codegen && vite",
"build": "panda codegen && tsc -b && vite build",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"preview": "vite preview",
"i18n:extract": "npx i18next -c i18next-parser.config.json"
"i18n:extract": "npx i18next -c i18next-parser.config.json",
"format": "prettier --write ./src",
"check": "prettier --check ./src"
},
"dependencies": {
"@livekit/components-react": "2.3.3",
@@ -40,10 +42,12 @@
"@typescript-eslint/parser": "7.13.1",
"@vitejs/plugin-react": "4.3.1",
"eslint": "8.57.0",
"eslint-config-prettier": "9.1.0",
"eslint-plugin-jsx-a11y": "6.9.0",
"eslint-plugin-react-hooks": "4.6.2",
"eslint-plugin-react-refresh": "0.4.7",
"postcss": "8.4.39",
"prettier": "3.3.3",
"typescript": "5.5.2",
"vite": "5.3.1",
"vite-tsconfig-paths": "4.3.2"
+2 -3
View File
@@ -10,9 +10,8 @@ import { Layout } from './layout/Layout'
import { NotFoundScreen } from './components/NotFoundScreen'
import { routes } from './routes'
import './i18n/init'
import { silenceLiveKitLogs } from "@/utils/livekit.ts";
import { queryClient } from "@/api/queryClient";
import { silenceLiveKitLogs } from '@/utils/livekit.ts'
import { queryClient } from '@/api/queryClient'
function App() {
const { i18n } = useTranslation()
+1 -1
View File
@@ -1,3 +1,3 @@
import { QueryClient } from "@tanstack/react-query";
import { QueryClient } from '@tanstack/react-query'
export const queryClient = new QueryClient()
+4 -1
View File
@@ -4,7 +4,10 @@ import { useTranslation } from 'react-i18next'
import { Center } from '@/styled-system/jsx'
import { Text } from '@/primitives'
export const ErrorScreen = ({ title, body }: {
export const ErrorScreen = ({
title,
body,
}: {
title?: string
body?: string
}) => {
@@ -4,5 +4,7 @@ export const authUrl = ({
silent = false,
returnTo = window.location.href,
} = {}) => {
return apiUrl(`/authenticate?silent=${encodeURIComponent(silent)}&returnTo=${encodeURIComponent(returnTo)}`)
return apiUrl(
`/authenticate?silent=${encodeURIComponent(silent)}&returnTo=${encodeURIComponent(returnTo)}`
)
}
@@ -1,20 +1,20 @@
import { authUrl } from "@/features/auth";
import { authUrl } from '@/features/auth'
const SILENT_LOGIN_RETRY_KEY = 'silent-login-retry'
const isRetryAllowed = () => {
const lastRetryDate = localStorage.getItem(SILENT_LOGIN_RETRY_KEY);
const lastRetryDate = localStorage.getItem(SILENT_LOGIN_RETRY_KEY)
if (!lastRetryDate) {
return true;
return true
}
const now = new Date();
const now = new Date()
return now.getTime() > Number(lastRetryDate)
}
const setNextRetryTime = (retryIntervalInSeconds: number) => {
const now = new Date()
const nextRetryTime = now.getTime() + (retryIntervalInSeconds * 1000);
localStorage.setItem(SILENT_LOGIN_RETRY_KEY, String(nextRetryTime));
const nextRetryTime = now.getTime() + retryIntervalInSeconds * 1000
localStorage.setItem(SILENT_LOGIN_RETRY_KEY, String(nextRetryTime))
}
const initiateSilentLogin = () => {
+10 -5
View File
@@ -9,18 +9,23 @@ import { generateRoomId } from '@/features/rooms'
import { authUrl, useUser, UserAware } from '@/features/auth'
import { JoinMeetingDialog } from '../components/JoinMeetingDialog'
import { useCreateRoom } from '@/features/rooms'
import { usePersistentUserChoices } from '@livekit/components-react'
export const Home = () => {
const { t } = useTranslation('home')
const { isLoggedIn } = useUser()
const {
userChoices: { username },
} = usePersistentUserChoices()
const { mutateAsync: createRoom } = useCreateRoom({
onSuccess: (data) => {
navigateTo('room', data.slug, {
state: { create: true, initialRoomData: data },
})
}
});
},
})
return (
<UserAware>
@@ -43,9 +48,9 @@ export const Home = () => {
onPress={
isLoggedIn
? async () => {
const slug = generateRoomId()
await createRoom({slug})
}
const slug = generateRoomId()
await createRoom({ slug, username })
}
: undefined
}
href={isLoggedIn ? undefined : authUrl()}
@@ -9,6 +9,6 @@ export type ApiRoom = {
token: string
}
configuration?: {
[key: string]: string | number | boolean;
[key: string]: string | number | boolean
}
}
@@ -1,25 +1,30 @@
import { useMutation, UseMutationOptions } from "@tanstack/react-query";
import { fetchApi } from '@/api/fetchApi';
import { ApiError } from "@/api/ApiError";
import { ApiRoom } from "./ApiRoom";
import { useMutation, UseMutationOptions } from '@tanstack/react-query'
import { fetchApi } from '@/api/fetchApi'
import { ApiError } from '@/api/ApiError'
import { ApiRoom } from './ApiRoom'
export interface CreateRoomParams {
slug: string;
slug: string
username?: string
}
const createRoom = ({slug}: CreateRoomParams): Promise<ApiRoom> => {
return fetchApi(`rooms/`, {
const createRoom = ({
slug,
username = '',
}: CreateRoomParams): Promise<ApiRoom> => {
return fetchApi(`rooms/?username=${encodeURIComponent(username)}`, {
method: 'POST',
body: JSON.stringify({
name: slug,
}),
})
};
}
export function useCreateRoom(options?: UseMutationOptions<ApiRoom, ApiError, CreateRoomParams>) {
export function useCreateRoom(
options?: UseMutationOptions<ApiRoom, ApiError, CreateRoomParams>
) {
return useMutation<ApiRoom, ApiError, CreateRoomParams>({
mutationFn: createRoom,
onSuccess: options?.onSuccess,
});
})
}
@@ -4,7 +4,6 @@ import { useTranslation } from 'react-i18next'
import {
formatChatMessageLinks,
LiveKitRoom,
VideoConference,
type LocalUserChoices,
} from '@livekit/components-react'
import { Room, RoomOptions } from 'livekit-client'
@@ -19,6 +18,8 @@ import { ApiRoom } from '../api/ApiRoom'
import { useCreateRoom } from '../api/createRoom'
import { InviteDialog } from './InviteDialog'
import { VideoConference } from '../livekit/prefabs/VideoConference'
export const Conference = ({
roomId,
userConfig,
@@ -32,14 +33,23 @@ export const Conference = ({
}) => {
const fetchKey = [keys.room, roomId, userConfig.username]
const { mutateAsync: createRoom, status: createStatus, isError: isCreateError} = useCreateRoom({
const {
mutateAsync: createRoom,
status: createStatus,
isError: isCreateError,
} = useCreateRoom({
onSuccess: (data) => {
queryClient.setQueryData(fetchKey, data)
},
});
})
const { status: fetchStatus, isError: isFetchError, data } = useQuery({
const {
status: fetchStatus,
isError: isFetchError,
data,
} = useQuery({
queryKey: fetchKey,
staleTime: 6 * 60 * 60 * 1000, // By default, LiveKit access tokens expire 6 hours after generation
enabled: !initialRoomData,
initialData: initialRoomData,
queryFn: () =>
@@ -48,7 +58,7 @@ export const Conference = ({
username: userConfig.username,
}).catch((error) => {
if (error.statusCode == '404') {
createRoom({slug: roomId})
createRoom({ slug: roomId, username: userConfig.username })
}
}),
retry: false,
@@ -92,12 +102,17 @@ export const Conference = ({
const { t } = useTranslation('rooms')
if (isCreateError) {
// this error screen should be replaced by a proper waiting room for anonymous user.
return <ErrorScreen title={t('error.createRoom.heading')} body={t('error.createRoom.body')} />
return (
<ErrorScreen
title={t('error.createRoom.heading')}
body={t('error.createRoom.body')}
/>
)
}
return (
<QueryAware status={isFetchError ? createStatus : fetchStatus}>
<Screen>
<Screen header={false}>
<LiveKitRoom
room={room}
serverUrl={data?.livekit?.url}
@@ -106,9 +121,7 @@ export const Conference = ({
audio={userConfig.audioEnabled}
video={userConfig.videoEnabled}
>
<VideoConference
chatMessageFormatter={formatChatMessageLinks}
/>
<VideoConference chatMessageFormatter={formatChatMessageLinks} />
{showInviteDialog && (
<InviteDialog
isOpen={showInviteDialog}
@@ -0,0 +1,56 @@
import { useTranslation } from 'react-i18next'
import { RiChat1Line } from '@remixicon/react'
import { Button } from '@/primitives'
import { css } from '@/styled-system/css'
import { useLayoutContext } from '@livekit/components-react'
import { useSnapshot } from 'valtio'
import { participantsStore } from '@/stores/participants'
export const ChatToggle = () => {
const { t } = useTranslation('rooms')
const { dispatch, state } = useLayoutContext().widget
const tooltipLabel = state?.showChat ? 'open' : 'closed'
const participantsSnap = useSnapshot(participantsStore)
const showParticipants = participantsSnap.showParticipants
return (
<div
className={css({
position: 'relative',
display: 'inline-block',
})}
>
<Button
toggle
square
legacyStyle
aria-label={t(`controls.chat.${tooltipLabel}`)}
tooltip={t(`controls.chat.${tooltipLabel}`)}
isSelected={state?.showChat}
onPress={() => {
if (showParticipants) participantsStore.showParticipants = false
if (dispatch) dispatch({ msg: 'toggle_chat' })
}}
>
<RiChat1Line />
</Button>
{!!state?.unreadMessages && (
<div
className={css({
position: 'absolute',
top: '-.25rem',
right: '-.25rem',
width: '1rem',
height: '1rem',
backgroundColor: 'red',
borderRadius: '50%',
zIndex: 1,
border: '2px solid #d1d5db',
})}
/>
)}
</div>
)
}
@@ -0,0 +1,22 @@
import { useTranslation } from 'react-i18next'
import { RiMore2Line } from '@remixicon/react'
import { Button } from '@/primitives'
import { OptionsMenu } from '@/features/rooms/livekit/components/controls/Options/OptionsMenu.tsx'
import { MenuTrigger } from 'react-aria-components'
export const OptionsButton = () => {
const { t } = useTranslation('rooms')
return (
<MenuTrigger>
<Button
square
legacyStyle
aria-label={t('options.buttonLabel')}
tooltip={t('options.buttonLabel')}
>
<RiMore2Line />
</Button>
<OptionsMenu />
</MenuTrigger>
)
}
@@ -0,0 +1,118 @@
import { useTranslation } from 'react-i18next'
import {
RiFeedbackLine,
RiQuestionLine,
RiSettings3Line,
RiUser5Line,
} from '@remixicon/react'
import { useState } from 'react'
import { styled } from '@/styled-system/jsx'
import {
Menu as RACMenu,
MenuItem as RACMenuItem,
Popover as RACPopover,
Separator as RACSeparator,
} from 'react-aria-components'
import { SettingsDialog } from '@/features/settings'
import { UsernameDialog } from '../../dialogs/UsernameDialog'
// Styled components to be refactored
const StyledMenu = styled(RACMenu, {
base: {
maxHeight: 'inherit',
boxSizing: 'border-box',
overflow: 'auto',
padding: '2px',
minWidth: '150px',
outline: 'none',
},
})
const StyledMenuItem = styled(RACMenuItem, {
base: {
margin: '2px',
padding: '0.286rem 0.571rem',
borderRadius: '6px',
outline: 'none',
cursor: 'default',
color: 'black',
fontSize: '1.072rem',
position: 'relative',
display: 'flex',
alignItems: 'center',
gap: '20px',
forcedColorAdjust: 'none',
'&[data-focused]': {
color: 'primary.text',
backgroundColor: 'primary',
outline: 'none!',
},
},
})
const StyledPopover = styled(RACPopover, {
base: {
border: '1px solid #9ca3af',
boxShadow: '0 8px 20px rgba(0, 0, 0, 0.1)',
borderRadius: '4px',
background: 'white',
color: 'var(--text-color)',
outline: 'none',
minWidth: '112px',
width: '300px',
},
})
const StyledSeparator = styled(RACSeparator, {
base: {
height: '1px',
background: '#9ca3af',
margin: '2px 4px',
},
})
type DialogState = 'username' | 'settings' | null
export const OptionsMenu = () => {
const { t } = useTranslation('rooms')
const [dialogOpen, setDialogOpen] = useState<DialogState>(null)
return (
<>
<StyledPopover>
<StyledMenu>
<StyledMenuItem onAction={() => setDialogOpen('username')}>
<RiUser5Line size={18} />
{t('options.items.username')}
</StyledMenuItem>
<StyledSeparator />
<StyledMenuItem
href="https://tchap.gouv.fr/#/room/!aGImQayAgBLjSBycpm:agent.dinum.tchap.gouv.fr?via=agent.dinum.tchap.gouv.fr"
target="_blank"
>
<RiQuestionLine size={18} />
{t('options.items.support')}
</StyledMenuItem>
<StyledMenuItem
href="https://grist.incubateur.net/o/docs/forms/1YrfNP1QSSy8p2gCxMFnSf/4"
target="_blank"
>
<RiFeedbackLine size={18} />
{t('options.items.feedbacks')}
</StyledMenuItem>
<StyledMenuItem onAction={() => setDialogOpen('settings')}>
<RiSettings3Line size={18} />
{t('options.items.settings')}
</StyledMenuItem>
</StyledMenu>
</StyledPopover>
<UsernameDialog
isOpen={dialogOpen === 'username'}
onOpenChange={(v) => !v && setDialogOpen(null)}
/>
<SettingsDialog
isOpen={dialogOpen === 'settings'}
onOpenChange={(v) => !v && setDialogOpen(null)}
/>
</>
)
}
@@ -0,0 +1,160 @@
import { css } from '@/styled-system/css'
import * as React from 'react'
import { useParticipants } from '@livekit/components-react'
import { Heading } from 'react-aria-components'
import { Box, Button, Div } from '@/primitives'
import { HStack, VStack } from '@/styled-system/jsx'
import { Text, text } from '@/primitives/Text'
import { RiCloseLine } from '@remixicon/react'
import { capitalize } from '@/utils/capitalize'
import { participantsStore } from '@/stores/participants'
import { useTranslation } from 'react-i18next'
import { allParticipantRoomEvents } from '@/features/rooms/livekit/constants/events'
export type AvatarProps = React.HTMLAttributes<HTMLSpanElement> & {
name: string
size?: number
}
// TODO - extract inline styling in a centralized styling file, and avoid magic numbers
export const Avatar = ({ name, size = 32 }: AvatarProps) => (
<div
className={css({
minWidth: `${size}px`,
minHeight: `${size}px`,
backgroundColor: '#3498db',
borderRadius: '50%',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
fontSize: '1.25rem',
userSelect: 'none',
cursor: 'default',
color: 'white',
})}
>
{name?.trim()?.charAt(0).toUpperCase()}
</div>
)
// TODO: Optimize rendering performance, especially for longer participant lists, even though they are generally short.
export const ParticipantsList = () => {
const { t } = useTranslation('rooms')
// Preferred using the 'useParticipants' hook rather than the separate remote and local hooks,
// because the 'useLocalParticipant' hook does not update the participant's information when their
// metadata/name changes. The LiveKit team has marked this as a TODO item in the code.
const participants = useParticipants({
updateOnlyOn: allParticipantRoomEvents,
})
const formattedParticipants = participants.map((participant) => ({
name: participant.name || participant.identity,
id: participant.identity,
}))
const sortedRemoteParticipants = formattedParticipants
.slice(1)
.sort((a, b) => a.name.localeCompare(b.name))
const allParticipants = [
formattedParticipants[0], // first participant returned by the hook, is always the local one
...sortedRemoteParticipants,
]
// TODO - extract inline styling in a centralized styling file, and avoid magic numbers
return (
<Box
size="sm"
minWidth="300px"
className={css({
overflow: 'hidden',
display: 'flex',
flexDirection: 'column',
margin: '1.5rem 1.5rem 1.5rem 0',
})}
>
<Heading slot="title" level={3} className={text({ variant: 'h2' })}>
<span>{t('participants.heading')}</span>{' '}
<span
className={css({
marginLeft: '0.75rem',
fontWeight: 'normal',
fontSize: '1rem',
})}
>
{participants?.length}
</span>
</Heading>
<Div position="absolute" top="5" right="5">
<Button
invisible
size="xs"
onPress={() => (participantsStore.showParticipants = false)}
aria-label={t('participants.closeButton')}
tooltip={t('participants.closeButton')}
>
<RiCloseLine />
</Button>
</Div>
{participants?.length && (
<VStack
role="list"
className={css({
alignItems: 'start',
gap: 'none',
overflowY: 'scroll',
overflowX: 'hidden',
minHeight: 0,
flexGrow: 1,
display: 'flex',
})}
>
{allParticipants.map((participant, index) => (
<HStack
role="listitem"
key={participant.id}
id={participant.id}
className={css({
padding: '0.25rem 0',
})}
>
<Avatar name={participant.name} />
<Text
variant={'sm'}
className={css({
userSelect: 'none',
cursor: 'default',
display: 'flex',
})}
>
<span
className={css({
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
maxWidth: '120px',
display: 'block',
})}
>
{capitalize(participant.name)}
</span>
{index === 0 && (
<span
className={css({
marginLeft: '.25rem',
whiteSpace: 'nowrap',
})}
>
({t('participants.you')})
</span>
)}
</Text>
</HStack>
))}
</VStack>
)}
</Box>
)
}
@@ -0,0 +1,75 @@
import { useTranslation } from 'react-i18next'
import { RiGroupLine, RiInfinityLine } from '@remixicon/react'
import { Button } from '@/primitives'
import { css } from '@/styled-system/css'
import { useLayoutContext, useParticipants } from '@livekit/components-react'
import { useSnapshot } from 'valtio'
import { participantsStore } from '@/stores/participants'
export const ParticipantsToggle = () => {
const { t } = useTranslation('rooms')
const { dispatch, state } = useLayoutContext().widget
/**
* Context could not be used due to inconsistent refresh behavior.
* The 'numParticipant' property on the room only updates when the room's metadata changes,
* resulting in a delay compared to the participant list's actual refresh rate.
*/
const participants = useParticipants()
const numParticipants = participants?.length
const participantsSnap = useSnapshot(participantsStore)
const showParticipants = participantsSnap.showParticipants
const tooltipLabel = showParticipants ? 'open' : 'closed'
return (
<div
className={css({
position: 'relative',
display: 'inline-block',
})}
>
<Button
toggle
square
legacyStyle
aria-label={t(`controls.participants.${tooltipLabel}`)}
tooltip={t(`controls.participants.${tooltipLabel}`)}
isSelected={showParticipants}
onPress={() => {
if (dispatch && state?.showChat) dispatch({ msg: 'toggle_chat' })
participantsStore.showParticipants = !showParticipants
}}
>
<RiGroupLine />
</Button>
<div
className={css({
position: 'absolute',
top: '-.25rem',
right: '-.25rem',
width: '1.25rem',
height: '1.25rem',
backgroundColor: 'gray',
borderRadius: '50%',
color: 'white',
fontSize: '0.75rem',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
textAlign: 'center',
zIndex: 1,
userSelect: 'none',
})}
>
{numParticipants < 100 ? (
numParticipants || 1
) : (
<RiInfinityLine size={10} />
)}
</div>
</div>
)
}
@@ -0,0 +1,52 @@
import {
useRoomContext,
useStartAudio,
useStartVideo,
} from '@livekit/components-react'
import React from 'react'
/** @public */
export interface AllowMediaPlaybackProps
extends React.ButtonHTMLAttributes<HTMLButtonElement> {
label?: string
}
/**
* The `StartMediaButton` component is only visible when the browser blocks media playback. This is due to some browser implemented autoplay policies.
* To start media playback, the user must perform a user-initiated event such as clicking this button.
* As soon as media playback starts, the button hides itself again.
*
* @example
* ```tsx
* <LiveKitRoom>
* <StartMediaButton label="Click to allow media playback" />
* </LiveKitRoom>
* ```
*
* @see Autoplay policy on MDN web docs: {@link https://developer.mozilla.org/en-US/docs/Web/API/Web_Audio_API/Best_practices#autoplay_policy}
* @public
*/
export const StartMediaButton: (
props: AllowMediaPlaybackProps & React.RefAttributes<HTMLButtonElement>
) => React.ReactNode = /* @__PURE__ */ React.forwardRef<
HTMLButtonElement,
AllowMediaPlaybackProps
>(function StartMediaButton({ label, ...props }: AllowMediaPlaybackProps, ref) {
const room = useRoomContext()
const { mergedProps: audioProps, canPlayAudio } = useStartAudio({
room,
props,
})
const { mergedProps, canPlayVideo } = useStartVideo({
room,
props: audioProps,
})
const { style, ...restProps } = mergedProps
style.display = canPlayAudio && canPlayVideo ? 'none' : 'block'
return (
<button ref={ref} style={style} {...restProps}>
{label ?? `Start ${!canPlayAudio ? 'Audio' : 'Video'}`}
</button>
)
})
@@ -0,0 +1,44 @@
import { useTranslation } from 'react-i18next'
import { Field, Form, Dialog, DialogProps } from '@/primitives'
import {
usePersistentUserChoices,
useRoomContext,
} from '@livekit/components-react'
export type UsernameDialogProps = Pick<DialogProps, 'isOpen' | 'onOpenChange'>
export const UsernameDialog = (props: UsernameDialogProps) => {
const { t } = useTranslation('rooms')
const { saveUsername } = usePersistentUserChoices()
const ctx = useRoomContext()
return (
<Dialog title={t('options.username.heading')} {...props}>
<Form
onSubmit={(data) => {
const { username } = data as { username: string }
ctx.localParticipant.setName(username)
saveUsername(username)
const { onOpenChange } = props
if (onOpenChange) {
onOpenChange(false)
}
}}
submitLabel={t('options.username.submitLabel')}
>
<Field
type="text"
name="username"
label={t('options.username.label')}
description={t('options.username.description')}
defaultValue={ctx.localParticipant.name}
validate={(value) => {
return !value ? (
<p>{t('options.username.validationError')}</p>
) : null
}}
/>
</Form>
</Dialog>
)
}
@@ -0,0 +1,33 @@
import { RoomEvent } from 'livekit-client'
// Issue: 'allRemoteParticipantRoomEvents' is not exposed or importable. One event is missing
// to trigger the real-time update of participants when they change their name.
// This code is duplicated from LiveKit.
export const allRemoteParticipantRoomEvents = [
RoomEvent.ConnectionStateChanged,
RoomEvent.RoomMetadataChanged,
RoomEvent.ActiveSpeakersChanged,
RoomEvent.ConnectionQualityChanged,
RoomEvent.ParticipantConnected,
RoomEvent.ParticipantDisconnected,
RoomEvent.ParticipantPermissionsChanged,
RoomEvent.ParticipantMetadataChanged,
RoomEvent.ParticipantNameChanged, // This element is missing in LiveKit and causes problems
RoomEvent.TrackMuted,
RoomEvent.TrackUnmuted,
RoomEvent.TrackPublished,
RoomEvent.TrackUnpublished,
RoomEvent.TrackStreamStateChanged,
RoomEvent.TrackSubscriptionFailed,
RoomEvent.TrackSubscriptionPermissionChanged,
RoomEvent.TrackSubscriptionStatusChanged,
]
export const allParticipantRoomEvents = [
...allRemoteParticipantRoomEvents,
RoomEvent.LocalTrackPublished,
RoomEvent.LocalTrackUnpublished,
]
@@ -0,0 +1,46 @@
import * as React from 'react'
/**
* Implementation used from https://github.com/juliencrn/usehooks-ts
*
* @internal
*/
export function useMediaQuery(query: string): boolean {
const getMatches = (query: string): boolean => {
// Prevents SSR issues
if (typeof window !== 'undefined') {
return window.matchMedia(query).matches
}
return false
}
const [matches, setMatches] = React.useState<boolean>(getMatches(query))
function handleChange() {
setMatches(getMatches(query))
}
React.useEffect(() => {
const matchMedia = window.matchMedia(query)
// Triggered at the first client-side load and if query changes
handleChange()
// Listen matchMedia
if (matchMedia.addListener) {
matchMedia.addListener(handleChange)
} else {
matchMedia.addEventListener('change', handleChange)
}
return () => {
if (matchMedia.removeListener) {
matchMedia.removeListener(handleChange)
} else {
matchMedia.removeEventListener('change', handleChange)
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [query])
return matches
}
@@ -0,0 +1,196 @@
import { Track } from 'livekit-client'
import * as React from 'react'
import { supportsScreenSharing } from '@livekit/components-core'
import {
DisconnectButton,
LeaveIcon,
MediaDeviceMenu,
TrackToggle,
useMaybeLayoutContext,
usePersistentUserChoices,
} from '@livekit/components-react'
import { mergeProps } from '@/utils/mergeProps.ts'
import { StartMediaButton } from '../components/controls/StartMediaButton'
import { useMediaQuery } from '../hooks/useMediaQuery'
import { useTranslation } from 'react-i18next'
import { OptionsButton } from '../components/controls/Options/OptionsButton'
import { ParticipantsToggle } from '@/features/rooms/livekit/components/controls/Participants/ParticipantsToggle'
import { ChatToggle } from '@/features/rooms/livekit/components/controls/ChatToggle'
/** @public */
export type ControlBarControls = {
microphone?: boolean
camera?: boolean
chat?: boolean
screenShare?: boolean
leave?: boolean
settings?: boolean
}
/** @public */
export interface ControlBarProps extends React.HTMLAttributes<HTMLDivElement> {
onDeviceError?: (error: { source: Track.Source; error: Error }) => void
variation?: 'minimal' | 'verbose' | 'textOnly'
controls?: ControlBarControls
/**
* If `true`, the user's device choices will be persisted.
* This will enables the user to have the same device choices when they rejoin the room.
* @defaultValue true
* @alpha
*/
saveUserChoices?: boolean
}
/**
* The `ControlBar` prefab gives the user the basic user interface to control their
* media devices (camera, microphone and screen share), open the `Chat` and leave the room.
*
* @remarks
* This component is build with other LiveKit components like `TrackToggle`,
* `DeviceSelectorButton`, `DisconnectButton` and `StartAudio`.
*
* @example
* ```tsx
* <LiveKitRoom>
* <ControlBar />
* </LiveKitRoom>
* ```
* @public
*/
export function ControlBar({
variation,
saveUserChoices = true,
onDeviceError,
...props
}: ControlBarProps) {
const { t } = useTranslation('rooms')
const [isChatOpen, setIsChatOpen] = React.useState(false)
const layoutContext = useMaybeLayoutContext()
React.useEffect(() => {
if (layoutContext?.widget.state?.showChat !== undefined) {
setIsChatOpen(layoutContext?.widget.state?.showChat)
}
}, [layoutContext?.widget.state?.showChat])
const isTooLittleSpace = useMediaQuery(
`(max-width: ${isChatOpen ? 1000 : 760}px)`
)
const defaultVariation = isTooLittleSpace ? 'minimal' : 'verbose'
variation ??= defaultVariation
const showIcon = React.useMemo(
() => variation === 'minimal' || variation === 'verbose',
[variation]
)
const showText = React.useMemo(
() => variation === 'textOnly' || variation === 'verbose',
[variation]
)
const browserSupportsScreenSharing = supportsScreenSharing()
const [isScreenShareEnabled, setIsScreenShareEnabled] = React.useState(false)
const onScreenShareChange = React.useCallback(
(enabled: boolean) => {
setIsScreenShareEnabled(enabled)
},
[setIsScreenShareEnabled]
)
const htmlProps = mergeProps({ className: 'lk-control-bar' }, props)
const {
saveAudioInputEnabled,
saveVideoInputEnabled,
saveAudioInputDeviceId,
saveVideoInputDeviceId,
} = usePersistentUserChoices({ preventSave: !saveUserChoices })
const microphoneOnChange = React.useCallback(
(enabled: boolean, isUserInitiated: boolean) =>
isUserInitiated ? saveAudioInputEnabled(enabled) : null,
[saveAudioInputEnabled]
)
const cameraOnChange = React.useCallback(
(enabled: boolean, isUserInitiated: boolean) =>
isUserInitiated ? saveVideoInputEnabled(enabled) : null,
[saveVideoInputEnabled]
)
return (
<div {...htmlProps}>
<div className="lk-button-group">
<TrackToggle
source={Track.Source.Microphone}
showIcon={showIcon}
onChange={microphoneOnChange}
onDeviceError={(error) =>
onDeviceError?.({ source: Track.Source.Microphone, error })
}
>
{showText && t('controls.microphone')}
</TrackToggle>
<div className="lk-button-group-menu">
<MediaDeviceMenu
kind="audioinput"
onActiveDeviceChange={(_kind, deviceId) =>
saveAudioInputDeviceId(deviceId ?? '')
}
/>
</div>
</div>
<div className="lk-button-group">
<TrackToggle
source={Track.Source.Camera}
showIcon={showIcon}
onChange={cameraOnChange}
onDeviceError={(error) =>
onDeviceError?.({ source: Track.Source.Camera, error })
}
>
{showText && t('controls.camera')}
</TrackToggle>
<div className="lk-button-group-menu">
<MediaDeviceMenu
kind="videoinput"
onActiveDeviceChange={(_kind, deviceId) =>
saveVideoInputDeviceId(deviceId ?? '')
}
/>
</div>
</div>
{browserSupportsScreenSharing && (
<TrackToggle
source={Track.Source.ScreenShare}
captureOptions={{ audio: true, selfBrowserSurface: 'include' }}
showIcon={showIcon}
onChange={onScreenShareChange}
onDeviceError={(error) =>
onDeviceError?.({ source: Track.Source.ScreenShare, error })
}
>
{showText &&
t(
isScreenShareEnabled
? 'controls.stopScreenShare'
: 'controls.shareScreen'
)}
</TrackToggle>
)}
<ChatToggle />
<ParticipantsToggle />
<OptionsButton />
<DisconnectButton>
{showIcon && <LeaveIcon />}
{showText && t('controls.leave')}
</DisconnectButton>
<StartMediaButton />
</div>
)
}
@@ -0,0 +1,222 @@
import type {
MessageDecoder,
MessageEncoder,
TrackReferenceOrPlaceholder,
WidgetState,
} from '@livekit/components-core'
import {
isEqualTrackRef,
isTrackReference,
isWeb,
log,
} from '@livekit/components-core'
import { RoomEvent, Track } from 'livekit-client'
import * as React from 'react'
import {
CarouselLayout,
ConnectionStateToast,
FocusLayout,
FocusLayoutContainer,
GridLayout,
LayoutContextProvider,
ParticipantTile,
RoomAudioRenderer,
MessageFormatter,
usePinnedTracks,
useTracks,
useCreateLayoutContext,
Chat,
} from '@livekit/components-react'
import { ControlBar } from './ControlBar'
import { styled } from '@/styled-system/jsx'
import { cva } from '@/styled-system/css'
import { ParticipantsList } from '@/features/rooms/livekit/components/controls/Participants/ParticipantsList'
import { useSnapshot } from 'valtio'
import { participantsStore } from '@/stores/participants'
const LayoutWrapper = styled(
'div',
cva({
base: {
position: 'relative',
display: 'flex',
width: '100%',
height: 'calc(100% - var(--lk-control-bar-height))',
},
})
)
/**
* @public
*/
export interface VideoConferenceProps
extends React.HTMLAttributes<HTMLDivElement> {
chatMessageFormatter?: MessageFormatter
chatMessageEncoder?: MessageEncoder
chatMessageDecoder?: MessageDecoder
/** @alpha */
SettingsComponent?: React.ComponentType
}
/**
* The `VideoConference` ready-made component is your drop-in solution for a classic video conferencing application.
* It provides functionality such as focusing on one participant, grid view with pagination to handle large numbers
* of participants, basic non-persistent chat, screen sharing, and more.
*
* @remarks
* The component is implemented with other LiveKit components like `FocusContextProvider`,
* `GridLayout`, `ControlBar`, `FocusLayoutContainer` and `FocusLayout`.
* You can use this components as a starting point for your own custom video conferencing application.
*
* @example
* ```tsx
* <LiveKitRoom>
* <VideoConference />
* <LiveKitRoom>
* ```
* @public
*/
export function VideoConference({
chatMessageFormatter,
chatMessageDecoder,
chatMessageEncoder,
...props
}: VideoConferenceProps) {
const [widgetState, setWidgetState] = React.useState<WidgetState>({
showChat: false,
unreadMessages: 0,
showSettings: false,
})
const lastAutoFocusedScreenShareTrack =
React.useRef<TrackReferenceOrPlaceholder | null>(null)
const tracks = useTracks(
[
{ source: Track.Source.Camera, withPlaceholder: true },
{ source: Track.Source.ScreenShare, withPlaceholder: false },
],
{ updateOnlyOn: [RoomEvent.ActiveSpeakersChanged], onlySubscribed: false }
)
const widgetUpdate = (state: WidgetState) => {
log.debug('updating widget state', state)
setWidgetState(state)
}
const layoutContext = useCreateLayoutContext()
const screenShareTracks = tracks
.filter(isTrackReference)
.filter((track) => track.publication.source === Track.Source.ScreenShare)
const focusTrack = usePinnedTracks(layoutContext)?.[0]
const carouselTracks = tracks.filter(
(track) => !isEqualTrackRef(track, focusTrack)
)
/* eslint-disable react-hooks/exhaustive-deps */
// Code duplicated from LiveKit; this warning will be addressed in the refactoring.
React.useEffect(() => {
// If screen share tracks are published, and no pin is set explicitly, auto set the screen share.
if (
screenShareTracks.some((track) => track.publication.isSubscribed) &&
lastAutoFocusedScreenShareTrack.current === null
) {
log.debug('Auto set screen share focus:', {
newScreenShareTrack: screenShareTracks[0],
})
layoutContext.pin.dispatch?.({
msg: 'set_pin',
trackReference: screenShareTracks[0],
})
lastAutoFocusedScreenShareTrack.current = screenShareTracks[0]
} else if (
lastAutoFocusedScreenShareTrack.current &&
!screenShareTracks.some(
(track) =>
track.publication.trackSid ===
lastAutoFocusedScreenShareTrack.current?.publication?.trackSid
)
) {
log.debug('Auto clearing screen share focus.')
layoutContext.pin.dispatch?.({ msg: 'clear_pin' })
lastAutoFocusedScreenShareTrack.current = null
}
if (focusTrack && !isTrackReference(focusTrack)) {
const updatedFocusTrack = tracks.find(
(tr) =>
tr.participant.identity === focusTrack.participant.identity &&
tr.source === focusTrack.source
)
if (
updatedFocusTrack !== focusTrack &&
isTrackReference(updatedFocusTrack)
) {
layoutContext.pin.dispatch?.({
msg: 'set_pin',
trackReference: updatedFocusTrack,
})
}
}
}, [
screenShareTracks
.map(
(ref) => `${ref.publication.trackSid}_${ref.publication.isSubscribed}`
)
.join(),
focusTrack?.publication?.trackSid,
tracks,
])
/* eslint-enable react-hooks/exhaustive-deps */
const participantsSnap = useSnapshot(participantsStore)
const showParticipants = participantsSnap.showParticipants
return (
<div className="lk-video-conference" {...props}>
{isWeb() && (
<LayoutContextProvider
value={layoutContext}
// onPinChange={handleFocusStateChange}
onWidgetChange={widgetUpdate}
>
<div className="lk-video-conference-inner">
<LayoutWrapper>
{!focusTrack ? (
<div
className="lk-grid-layout-wrapper"
style={{ height: 'auto' }}
>
<GridLayout tracks={tracks}>
<ParticipantTile />
</GridLayout>
</div>
) : (
<div className="lk-focus-layout-wrapper">
<FocusLayoutContainer>
<CarouselLayout tracks={carouselTracks}>
<ParticipantTile />
</CarouselLayout>
{focusTrack && <FocusLayout trackRef={focusTrack} />}
</FocusLayoutContainer>
</div>
)}
<Chat
style={{ display: widgetState.showChat ? 'grid' : 'none' }}
messageFormatter={chatMessageFormatter}
messageEncoder={chatMessageEncoder}
messageDecoder={chatMessageDecoder}
/>
{showParticipants && <ParticipantsList />}
</LayoutWrapper>
<ControlBar />
</div>
</LayoutContextProvider>
)}
<RoomAudioRenderer />
<ConnectionStateToast />
</div>
)
}
@@ -8,7 +8,9 @@ export const FeedbackRoute = () => {
return (
<Screen layout="centered">
<CenteredContent title={t('feedback.heading')} withBackButton>
<Text as="p" variant="h3" centered>{t('feedback.body')}</Text>
<Text as="p" variant="h3" centered>
{t('feedback.body')}
</Text>
</CenteredContent>
</Screen>
)
@@ -1,4 +1,4 @@
import { useState } from 'react'
import { useEffect, useState } from 'react'
import {
usePersistentUserChoices,
type LocalUserChoices,
@@ -19,6 +19,19 @@ export const Room = () => {
const mode = isLoggedIn && history.state?.create ? 'create' : 'join'
const skipJoinScreen = isLoggedIn && mode === 'create'
const clearRouterState = () => {
if (window?.history?.state) {
window.history.replaceState({}, '')
}
}
useEffect(() => {
window.addEventListener('beforeunload', clearRouterState)
return () => {
window.removeEventListener('beforeunload', clearRouterState)
}
}, [])
if (!roomId) {
return <ErrorScreen />
}
@@ -1,12 +1,38 @@
import { useTranslation } from 'react-i18next'
import { Trans, useTranslation } from 'react-i18next'
import { useLanguageLabels } from '@/i18n/useLanguageLabels'
import { Dialog, Field, H } from '@/primitives'
import { A, Badge, Dialog, type DialogProps, Field, H, P } from '@/primitives'
import { authUrl, logoutUrl, useUser } from '@/features/auth'
export const SettingsDialog = () => {
export type SettingsDialogProps = Pick<DialogProps, 'isOpen' | 'onOpenChange'>
export const SettingsDialog = (props: SettingsDialogProps) => {
const { t, i18n } = useTranslation('settings')
const { user, isLoggedIn } = useUser()
const { languagesList, currentLanguage } = useLanguageLabels()
return (
<Dialog title={t('dialog.heading')}>
<Dialog title={t('dialog.heading')} {...props}>
<H lvl={2}>{t('account.heading')}</H>
{isLoggedIn ? (
<>
<P>
<Trans
i18nKey="settings:account.currentlyLoggedAs"
values={{ user: user?.email }}
components={[<Badge />]}
/>
</P>
<P>
<A href={logoutUrl()}>{t('logout', { ns: 'global' })}</A>
</P>
</>
) : (
<>
<P>{t('account.youAreNotLoggedIn')}</P>
<P>
<A href={authUrl()}>{t('login', { ns: 'global' })}</A>
</P>
</>
)}
<H lvl={2}>{t('language.heading')}</H>
<Field
type="select"
+3 -1
View File
@@ -74,7 +74,9 @@ export const Header = () => {
{user.email}
</Button>
<PopoverList
items={[{ key: 'logout', value: 'logout', label: t('logout') }]}
items={[
{ key: 'logout', value: 'logout', label: t('logout') },
]}
onAction={(value) => {
if (value === 'logout') {
window.location.href = logoutUrl()
+36
View File
@@ -22,5 +22,41 @@
"heading": "",
"body": ""
}
},
"controls": {
"microphone": "",
"camera": "",
"shareScreen": "",
"stopScreenShare": "",
"chat": {
"open": "",
"closed": ""
},
"leave": "",
"participants": {
"open": "",
"closed": ""
}
},
"options": {
"buttonLabel": "",
"items": {
"feedbacks": "",
"support": "",
"settings": "",
"username": ""
},
"username": {
"heading": "",
"label": "",
"description": "",
"validationError": "",
"submitLabel": ""
}
},
"participants": {
"heading": "",
"closeButton": "",
"you": ""
}
}
@@ -1,4 +1,9 @@
{
"account": {
"currentlyLoggedAs": "",
"heading": "",
"youAreNotLoggedIn": ""
},
"dialog": {
"heading": ""
},
+36
View File
@@ -22,5 +22,41 @@
"heading": "Authentication Required",
"body": "This room has not been created yet. Please authenticate to create it or wait for an authenticated user to do so."
}
},
"controls": {
"microphone": "Microphone",
"camera": "Camera",
"shareScreen": "Share screen",
"stopScreenShare": "Stop screen share",
"chat": {
"open": "Close the chat",
"closed": "Open the chat"
},
"leave": "Leave",
"participants": {
"open": "Hide everyone",
"closed": "See everyone"
}
},
"options": {
"buttonLabel": "More Options",
"items": {
"feedbacks": "Give us feedbacks",
"support": "Get Help on Tchap",
"settings": "Settings",
"username": "Update Your Name"
},
"username": {
"heading": "Update Your Name",
"label": "Your Name",
"description": "All other participants will see this name.",
"validationError": "Name cannot be empty.",
"submitLabel": "Save"
}
},
"participants": {
"heading": "Participants",
"closeButton": "Hide participants",
"you": "You"
}
}
@@ -1,4 +1,9 @@
{
"account": {
"currentlyLoggedAs": "You are currently logged in as <0>{{user}}</0>",
"heading": "Account",
"youAreNotLoggedIn": "You are not logged in."
},
"dialog": {
"heading": "Settings"
},
+36
View File
@@ -22,5 +22,41 @@
"heading": "Authentification requise",
"body": "Cette réunion n'a pas encore été créée. Veuillez vous authentifier pour la créer ou attendre qu'un utilisateur authentifié le fasse."
}
},
"controls": {
"microphone": "Microphone",
"camera": "Camera",
"shareScreen": "Partager l'écran",
"stopScreenShare": "Arrêter le partage",
"chat": {
"open": "Masquer le chat",
"closed": "Afficher le chat"
},
"leave": "Quitter",
"participants": {
"open": "Masquer les participants",
"closed": "Afficher les participants"
}
},
"options": {
"buttonLabel": "Plus d'options",
"items": {
"feedbacks": "Partager votre avis",
"support": "Obtenir de l'aide sur Tchap",
"settings": "Paramètres",
"username": "Choisir votre nom"
},
"username": {
"heading": "Choisir votre nom",
"label": "Votre Nom",
"description": "Tous les autres participants verront ce nom.",
"validationError": "Le nom ne peut pas être vide.",
"submitLabel": "Enregistrer"
}
},
"participants": {
"heading": "Participants",
"closeButton": "Masquer les participants",
"you": "Vous"
}
}
@@ -1,4 +1,9 @@
{
"account": {
"currentlyLoggedAs": "Vous êtes actuellement connecté en tant que <0>{{user}}</0>",
"heading": "Compte",
"youAreNotLoggedIn": "Vous n'êtes pas connecté."
},
"dialog": {
"heading": "Paramètres"
},
+39 -1
View File
@@ -1,10 +1,12 @@
import { type ReactNode } from 'react'
import {
Button as RACButton,
ToggleButton as RACToggleButton,
type ButtonProps as RACButtonsProps,
TooltipTrigger,
Link,
LinkProps,
ToggleButtonProps as RACToggleButtonProps,
} from 'react-aria-components'
import { cva, type RecipeVariantProps } from '@/styled-system/css'
import { Tooltip } from './Tooltip'
@@ -19,6 +21,9 @@ const button = cva({
border: '1px solid transparent',
color: 'colorPalette.text',
backgroundColor: 'colorPalette',
'&[data-selected]': {
background: 'colorPalette.active',
},
'&[data-hovered]': {
backgroundColor: 'colorPalette.hover',
},
@@ -103,6 +108,24 @@ const button = cva({
width: 'full',
},
},
legacyStyle: {
true: {
borderColor: 'gray.400',
'&[data-hovered]': {
borderColor: 'gray.500',
},
'&[data-pressed]': {
borderColor: 'gray.500',
},
'&[data-selected]': {
background: '#e5e7eb',
borderColor: 'gray.400',
'&[data-hovered]': {
backgroundColor: 'gray.300',
},
},
},
},
},
defaultVariants: {
size: 'default',
@@ -117,7 +140,10 @@ type Tooltip = {
}
export type ButtonProps = RecipeVariantProps<typeof button> &
RACButtonsProps &
Tooltip
Tooltip & {
toggle?: boolean
isSelected?: boolean
}
type LinkButtonProps = RecipeVariantProps<typeof button> & LinkProps & Tooltip
@@ -136,6 +162,18 @@ export const Button = ({
</TooltipWrapper>
)
}
if ((props as ButtonProps).toggle) {
return (
<TooltipWrapper tooltip={tooltip} tooltipType={tooltipType}>
<RACToggleButton
className={button(variantProps)}
{...(componentProps as RACToggleButtonProps)}
/>
</TooltipWrapper>
)
}
return (
<TooltipWrapper tooltip={tooltip} tooltipType={tooltipType}>
<RACButton
+1 -1
View File
@@ -107,7 +107,7 @@ export const Dialog = ({
? children({ close })
: children}
{!isAlert && (
<Div position="absolute" top="0" right="0">
<Div position="absolute" top="5" right="5">
<Button
invisible
size="xs"
+1 -1
View File
@@ -42,7 +42,7 @@ export const PopoverList = <T extends string | number = string>({
}: {
closeOnAction?: boolean
onAction: (key: T) => void
items: Array<string | { key: string, value: T; label: ReactNode }>
items: Array<string | { key: string; value: T; label: ReactNode }>
} & ButtonProps) => {
const popoverState = useContext(OverlayTriggerStateContext)!
return (
+9
View File
@@ -0,0 +1,9 @@
import { proxy } from 'valtio'
type State = {
showParticipants: boolean
}
export const participantsStore = proxy<State>({
showParticipants: false,
})
+7
View File
@@ -0,0 +1,7 @@
export function capitalize(string: string) {
if (!string) {
return string
}
const trimmed = string.trim()
return trimmed.charAt(0).toUpperCase() + trimmed.slice(1)
}
+2 -3
View File
@@ -1,6 +1,5 @@
import { LogLevel, setLogLevel } from "livekit-client";
import { LogLevel, setLogLevel } from 'livekit-client'
export const silenceLiveKitLogs = (shouldSilenceLogs: boolean) => {
setLogLevel(shouldSilenceLogs ? LogLevel.silent : LogLevel.debug);
setLogLevel(shouldSilenceLogs ? LogLevel.silent : LogLevel.debug)
}
+95
View File
@@ -0,0 +1,95 @@
/*
* Copyright 2020 Adobe. All rights reserved.
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. You may obtain a copy
* of the License at http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software distributed under
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
* OF ANY KIND, either express or implied. See the License for the specific language
* governing permissions and limitations under the License.
*/
/* eslint-disable @typescript-eslint/no-explicit-any */
import clsx from 'clsx'
/**
* Calls all functions in the order they were chained with the same arguments.
* @internal
*/
export function chain(...callbacks: any[]): (...args: any[]) => void {
return (...args: any[]) => {
for (const callback of callbacks) {
if (typeof callback === 'function') {
try {
callback(...args)
} catch (e) {
console.error(e)
}
}
}
}
}
interface Props {
[key: string]: any
}
// taken from: https://stackoverflow.com/questions/51603250/typescript-3-parameter-list-intersection-type/51604379#51604379
type TupleTypes<T> = { [P in keyof T]: T[P] } extends { [key: number]: infer V }
? V
: never
type UnionToIntersection<U> = (U extends any ? (k: U) => void : never) extends (
k: infer I
) => void
? I
: never
/**
* Merges multiple props objects together. Event handlers are chained,
* classNames are combined, and ids are deduplicated - different ids
* will trigger a side-effect and re-render components hooked up with `useId`.
* For all other props, the last prop object overrides all previous ones.
* @param args - Multiple sets of props to merge together.
* @internal
*/
export function mergeProps<T extends Props[]>(
...args: T
): UnionToIntersection<TupleTypes<T>> {
// Start with a base clone of the first argument. This is a lot faster than starting
// with an empty object and adding properties as we go.
const result: Props = { ...args[0] }
for (let i = 1; i < args.length; i++) {
const props = args[i]
for (const key in props) {
const a = result[key]
const b = props[key]
// Chain events
if (
typeof a === 'function' &&
typeof b === 'function' &&
// This is a lot faster than a regex.
key[0] === 'o' &&
key[1] === 'n' &&
key.charCodeAt(2) >= /* 'A' */ 65 &&
key.charCodeAt(2) <= /* 'Z' */ 90
) {
result[key] = chain(a, b)
// Merge classnames, sometimes classNames are empty string which eval to false, so we just need to do a type check
} else if (
(key === 'className' || key === 'UNSAFE_className') &&
typeof a === 'string' &&
typeof b === 'string'
) {
result[key] = clsx(a, b)
} else {
result[key] = b !== undefined ? b : a
}
}
}
return result as UnionToIntersection<TupleTypes<T>>
}
+2 -1
View File
@@ -8,9 +8,10 @@ backend:
envVars:
DJANGO_CSRF_TRUSTED_ORIGINS: https://meet.127.0.0.1.nip.io,http://meet.127.0.0.1.nip.io
DJANGO_CONFIGURATION: Production
DJANGO_ALLOWED_HOSTS: "*"
DJANGO_ALLOWED_HOSTS: meet.127.0.0.1.nip.io
DJANGO_SECRET_KEY: {{ .Values.djangoSecretKey }}
DJANGO_SETTINGS_MODULE: meet.settings
DJANGO_SILENCED_SYSTEM_CHECKS: security.W004, security.W008
DJANGO_SUPERUSER_PASSWORD: admin
DJANGO_EMAIL_HOST: "mailcatcher"
DJANGO_EMAIL_PORT: 1025
@@ -1,7 +1,7 @@
image:
repository: lasuite/meet-backend
pullPolicy: Always
tag: "v0.1.3"
tag: "v0.1.4"
backend:
migrateJobAnnotations:
@@ -10,7 +10,7 @@ backend:
envVars:
DJANGO_CSRF_TRUSTED_ORIGINS: http://meet-preprod.beta.numerique.gouv.fr,https://meet-preprod.beta.numerique.gouv.fr
DJANGO_CONFIGURATION: Production
DJANGO_ALLOWED_HOSTS: "*"
DJANGO_ALLOWED_HOSTS: meet-preprod.beta.numerique.gouv.fr
DJANGO_SUPERUSER_EMAIL:
secretKeyRef:
name: backend
@@ -20,6 +20,7 @@ backend:
name: backend
key: DJANGO_SECRET_KEY
DJANGO_SETTINGS_MODULE: meet.settings
DJANGO_SILENCED_SYSTEM_CHECKS: security.W004, security.W008
DJANGO_SUPERUSER_PASSWORD:
secretKeyRef:
name: backend
@@ -107,7 +108,7 @@ frontend:
image:
repository: lasuite/meet-frontend
pullPolicy: Always
tag: "v0.1.3"
tag: "v0.1.4"
ingress:
enabled: true
@@ -1,7 +1,7 @@
image:
repository: lasuite/meet-backend
pullPolicy: Always
tag: "v0.1.3"
tag: "v0.1.4"
backend:
migrateJobAnnotations:
@@ -10,12 +10,13 @@ backend:
envVars:
DJANGO_CSRF_TRUSTED_ORIGINS: https://meet.numerique.gouv.fr
DJANGO_CONFIGURATION: Production
DJANGO_ALLOWED_HOSTS: "*"
DJANGO_ALLOWED_HOSTS: meet.numerique.gouv.fr
DJANGO_SECRET_KEY:
secretKeyRef:
name: backend
key: DJANGO_SECRET_KEY
DJANGO_SETTINGS_MODULE: meet.settings
DJANGO_SILENCED_SYSTEM_CHECKS: security.W004, security.W008
DJANGO_SUPERUSER_EMAIL:
secretKeyRef:
name: backend
@@ -108,7 +109,7 @@ frontend:
image:
repository: lasuite/meet-frontend
pullPolicy: Always
tag: "v0.1.3"
tag: "v0.1.4"
ingress:
enabled: true
@@ -10,7 +10,7 @@ backend:
envVars:
DJANGO_CSRF_TRUSTED_ORIGINS: http://meet-staging.beta.numerique.gouv.fr,https://meet-staging.beta.numerique.gouv.fr
DJANGO_CONFIGURATION: Production
DJANGO_ALLOWED_HOSTS: "*"
DJANGO_ALLOWED_HOSTS: meet-staging.beta.numerique.gouv.fr
DJANGO_SECRET_KEY:
secretKeyRef:
name: backend
+2 -2
View File
@@ -143,10 +143,10 @@ backend:
probes:
liveness:
path: /__heartbeat__
initialDelaySeconds: 10
initialDelaySeconds: 30
readiness:
path: /__lbheartbeat__
initialDelaySeconds: 10
initialDelaySeconds: 30
## @param backend.resources Resource requirements for the backend container
resources: {}
+1705
View File
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "mail_mjml",
"version": "0.1.3",
"version": "0.1.4",
"description": "An util to generate html and text django's templates from mjml templates",
"type": "module",
"dependencies": {
@@ -11,7 +11,7 @@
"scripts": {
"build-mjml-to-html": "bash ./bin/mjml-to-html",
"build-html-to-plain-text": "bash ./bin/html-to-plain-text",
"build": "yarn build-mjml-to-html && yarn build-html-to-plain-text"
"build": "npm run build-mjml-to-html && npm run build-html-to-plain-text"
},
"volta": {
"node": "16.15.1"
-1292
View File
File diff suppressed because it is too large Load Diff