Compare commits

..

29 Commits

Author SHA1 Message Date
lebaudantoine e7dc54d6c5 🔖(minor) bump release to 0.1.3 2024-08-05 23:01:26 +02:00
lebaudantoine 9a07fba991 💄(frontend) center the feedback screen text
Align the screen with the mockup, and the recent added error screen.
2024-08-05 22:28:45 +02:00
lebaudantoine ca3b1f0297 (frontend) center error screen body
Added a new props on Text primitive to allow centering
the text if needed. It's align the design with the mockup.
2024-08-05 22:28:45 +02:00
lebaudantoine 01390b12fb (frontend) allow customization of error title and description
Enhanced the error screen to support customizable titles and descriptions.

Allows for more detailed and informative error messages for users.
By default, the error screen displays a standard heading. You can
now pass custom title and body content to provide additional context
and clarity.
2024-08-05 22:28:45 +02:00
lebaudantoine df1eca7c34 (frontend) support get or create room while accessing a room
Attempt to create a room using a mutation if the fetch query fails.
If the mutation succeeds, update the fetch query data to ensure
it is up-to-date.

I tried to reproduce kind of a ‘get or create’ mechanism.

Due to the removal of the 'onError' prop in React Query, it is recommended
to use useEffect for handling such event propagation.

Regarding status handling, the fetch status takes priority over the mutation.
The mutation should only be called if the fetch has failed.
2024-08-05 22:28:45 +02:00
lebaudantoine b529e9c848 ♻️(frontend) extract query key in a variable
I'll need this key to sync data when the mutation responds successfully.
This is a preliminary refactoring; the mutation will be added
in upcoming commits.
2024-08-05 22:28:45 +02:00
lebaudantoine 23a2d3bcac ♻️(frontend) support mutation status in QueryAware component
useMutation has one more possible status, 'idle'. I was forced
to add it to avoid any Typescript error.
2024-08-05 22:28:45 +02:00
lebaudantoine 55749a9565 ♻️(frontend) extract queryClient definition
It will be necessary to access the client in other components for manually
discarding or setting data in the central data store.
2024-08-05 22:28:45 +02:00
lebaudantoine 8115a39538 🐛(frontend) make link in the chat clickable
This is based on LiveKit demo app.
Used the LiveKit default chat messages formatter to make the link
clickable by any user.
2024-08-05 22:28:45 +02:00
lebaudantoine 2416ca1127 🔥(frontend) remove default username
Backend defaults username to the user's email, if the frontend
haven't pass an username value.

Remove this useless check.
2024-08-05 22:28:45 +02:00
lebaudantoine 1971f594cf 🔇(frontend) remove LiveKit logs in production
Enabling logs by default in clients during production feels insecure.
Therefore, I have silenced all LiveKit logs.

This might pose a challenge if we need to debug a user in the future,
but currently, we don’t require these logs.

Additionally, my utils file and function work as intended,
though there is room for improvement.
2024-08-05 22:28:45 +02:00
lebaudantoine fb0ee9e8f6 (frontend) create room in db when creating a new room
With unregistered rooms being now forbidden, we need to call create a room
in our Django db to get an access token.

Clicking on the 'Create room' will create a new entry using a Post request.
The output serialized already contains an access token to the LiveKit server,
thus we won't need to run the useQuery hook when navigating to the room.

/!\ this modification now prevent authenticated users to create rooms
by simply navigating to it. It'll be fixed in the upcoming commits.
2024-08-05 22:28:45 +02:00
lebaudantoine b261f2ee5b 🛂(backend) disallow unregistered rooms
Require users to create a room in the database
before requesting a LiveKit token.

If user request an access token for a room that doesn't
exist in our db, its request would end in a 404 error.

Ensure that rooms must be registered by a user before they can be accessed.
By default, all created rooms remain public, allowing anonymous users to join
any room created by a logged-in user.

However, anonymous users cannot create rooms themselves.
2024-08-05 22:28:45 +02:00
lebaudantoine aa54075e6b 📈(helm) add separate namespaces for each environment
I have set up distinct namespaces for each environment. You can now push
events to the development namespace without affecting production data.

Please note that these keys are not 'secret'. They will also be configured
in the browser SDK, which is inherently insecure. The documentation does not
specify a secure storage method for these keys.
2024-08-05 17:30:12 +02:00
lebaudantoine 271b598cee 📈(backend) introduce analytics
In this commit, we'll integrate a third-party service to track user events.
We start by using the `identify` method to track sign-ins and sign-ups.

Additionally, we use the `track` method to monitor custom events such as room
creation, access token generation, and logouts. This will provide us with
valuable data on current usage patterns.

The analytics library operates by opening a queue in a separate thread for
posting events, ensuring it remains non-blocking for the API. Let's test
this in a real-world scenario.
2024-08-05 17:30:12 +02:00
lebaudantoine fc232759fb (backend) support email anonymization on user
Add a new property 'email_anonymized' to the User model,
to allow tracking a user's email without any personal data.

In fact, we're dealing with professional data, thus it shouldn't
be subject to the GDPR, however I prefer taking extra care
when working with potentially first and last names.
2024-08-05 17:30:12 +02:00
lebaudantoine a992aa8898 (backend) add analytics dependency
I've chosen June, a closed project, for our product analysis. Please note that
this is temporary until we find our product-market fit and achieve
significant traction.

I selected June for several reasons, particularly their focus on pre-product-
market fit (PMF) analysis, which is crucial for us. Their approach will help us
track user engagement and identify the most important features.

Remember, the purpose of this data is not to provide definitive answers about
our product, but to prompt us to ask the right questions and engage with users
to find the answers.
2024-08-05 17:30:12 +02:00
renovate[bot] 1b8b91a44d ⬆️(dependencies) update python dependencies 2024-08-05 10:01:15 +02:00
lebaudantoine 63c6f5a8a1 🐛(frontend) add unique key to 'popover' items
Close issue #91. Each <li> element in a list should have a unique key
to avoid React warnings.

Added unique keys to 'popover' items. Although the 'lang' keys are generated
by i18n dependencies and are expected to be unique, this implementation
ensures keys are unique and not based on indexes.
2024-08-04 23:59:41 +02:00
lebaudantoine 1970a4d6b1 ✏️(frontend) fix minor typo in i18n hook
Oupsi, found a typo and fixed it.
2024-08-04 23:59:41 +02:00
lebaudantoine d406f31bd8 🔧(backend) fix Pylint configurations
Removing the __init__.py makes it impossible for Pylint to get the sources
to lint from the root folder. We manually set all the paths pylint will lint.

That's not a big deal, as we'll remove Pylint any soon to rely only on ruff.
I took inspiration from marsha or magnify project.

I removed the now useless bash script to run Pylint command. It saves us
wrapper! Plus, having a lint command running with different option locally
and in the CI was quite a pain.

Locally linter was running on diff files; Fixed! CI and make command has now
the same behavior.
2024-07-31 13:12:30 +02:00
lebaudantoine 4a011024dd 🚨(backend) fix Ruff warnings
Ruff is configured in the pyproject.toml file.
Its configuration was deprecated. It triggered warning in the console.
Updated the pyproject.toml file.
2024-07-31 13:12:30 +02:00
lebaudantoine 59b23ad1b9 ⚰️(backend) remove unused setup file
We are now using pyproject.toml, cleaned legacy files that weren't removed
when bootstraping the project.
2024-07-31 13:12:30 +02:00
lebaudantoine 93be2881d2 (backend) fix tests broken by dependencies updates
Recent updates in dependencies broke the tests.
I am in a rush, I found a stack overflow discussion mentionning we should
NOT consider the root folder of a Django project as a Python package.

My issue was:
Model class app.core.models.User doesn't declare an explicit app_label
and isn't in an application in INSTALLED_APPS.

Removing the __ini__.py file at the root folder fixed the regression.
2024-07-31 13:12:30 +02:00
lebaudantoine daa125edf3 🚨(backend) fix linter warnings
Recent updates of dev/ruff and dev/pylint dependencies led
to new linting warnings.

Pylint 3.2.0 introduced a new check `possibly-used-before-assignment`,
which ensures variables are defined regardless of conditional statements.

Some if/else branches were missing defaults. These have been fixed.
2024-07-31 13:12:30 +02:00
renovate[bot] c93e770704 ⬆️(dependencies) update python dependencies 2024-07-31 13:12:30 +02:00
Emmanuel Pelletier 1f57adc4da ♻️(frontend) rework how we handle screens and layout code
Previously each route rendered its whole layout from a to z.
Now each route updates a single common wrapper when the layout changes
between pages.

Also the Loading, Error, UserFetched, QueryAware code is more explicit
and understandable.

This introduces valtio as dependency, allowing us to deal with global
state easily in a svelte/vue way (reactive mutable state). This limits
the boilerplaty-ness of immutable state lib approaches, while keeping
rendering optimization better than homemade react contexts.
2024-07-30 16:26:24 +02:00
Emmanuel Pelletier 952e6970f0 🐛(room) do not show feedback info when reloading room page
the livekit prefab component doesn't expose what we want, rely on the
dom for now until we redo the component from scratch
2024-07-30 01:47:03 +02:00
Emmanuel Pelletier af0746eac1 ️(frontend) dont endlessly query user info
we query user at load and keep info for a while, way less than user
session anyway. will need to check if we could get de-sync (like loading
the frontend with backend user session ending in 30 minutes while we
don't check user state until an hour)
2024-07-30 01:37:20 +02:00
86 changed files with 875 additions and 353 deletions
+1 -1
View File
@@ -89,7 +89,7 @@ jobs:
- name: Lint code with ruff
run: ~/.local/bin/ruff check .
- name: Lint code with pylint
run: ~/.local/bin/pylint .
run: ~/.local/bin/pylint meet demo core
test-back:
runs-on: ubuntu-latest
+1 -1
View File
@@ -141,7 +141,7 @@ lint-ruff-check: ## lint back-end python sources with ruff
lint-pylint: ## lint back-end python sources with pylint only on changed files from main
@echo 'lint:pylint started…'
bin/pylint --diff-only=origin/main
@$(COMPOSE_RUN_APP) pylint meet demo core
.PHONY: lint-pylint
test: ## run project tests
-38
View File
@@ -1,38 +0,0 @@
#!/usr/bin/env bash
# shellcheck source=bin/_config.sh
source "$(dirname "${BASH_SOURCE[0]}")/_config.sh"
declare diff_from
declare -a paths
declare -a args
# Parse options
for arg in "$@"
do
case $arg in
--diff-only=*)
diff_from="${arg#*=}"
shift
;;
-*)
args+=("$arg")
shift
;;
*)
paths+=("$arg")
shift
;;
esac
done
if [[ -n "${diff_from}" ]]; then
# Run pylint only on modified files located in src/backend
# (excluding deleted files and migration files)
# shellcheck disable=SC2207
paths=($(git diff "${diff_from}" --name-only --diff-filter=d -- src/backend ':!**/migrations/*.py' | grep -E '^src/backend/.*\.py$'))
fi
# Fix docker vs local path when project sources are mounted as a volume
read -ra paths <<< "$(echo "${paths[@]}" | sed "s|src/backend/||g")"
_dc_run app-dev pylint "${paths[@]}" "${args[@]}"
+1
View File
@@ -41,3 +41,4 @@ OIDC_AUTH_REQUEST_EXTRA_PARAMS={"acr_values": "eidas1"}
LIVEKIT_API_SECRET=secret
LIVEKIT_API_KEY=devkey
LIVEKIT_API_URL=http://localhost:7880
ALLOW_UNREGISTERED_ROOMS=False
View File
+1
View File
@@ -1,4 +1,5 @@
"""Admin classes and registrations for core app."""
from django.contrib import admin
from django.contrib.auth import admin as auth_admin
from django.utils.translation import gettext_lazy as _
+58
View File
@@ -0,0 +1,58 @@
"""
Meet analytics class.
"""
import uuid
from django.conf import settings
from june import analytics as jAnalytics
class Analytics:
"""Analytics integration
This class wraps the June analytics code to avoid coupling our code directly
with this third-party library. By doing so, we create a generic interface
for analytics that can be easily modified or replaced in the future.
"""
def __init__(self):
key = getattr(settings, "ANALYTICS_KEY", None)
if key is not None:
jAnalytics.write_key = key
self._enabled = key is not None
def _is_anonymous_user(self, user):
"""Check if the user is anonymous."""
return user is None or user.is_anonymous
def identify(self, user, **kwargs):
"""Identify a user"""
if self._is_anonymous_user(user) or not self._enabled:
return
traits = kwargs.pop("traits", {})
traits.update({"email": user.email_anonymized})
jAnalytics.identify(user_id=user.sub, traits=traits, **kwargs)
def track(self, user, **kwargs):
"""Track an event"""
if not self._enabled:
return
event_data = {}
if self._is_anonymous_user(user):
event_data["anonymous_id"] = str(uuid.uuid4())
else:
event_data["user_id"] = user.sub
jAnalytics.track(**event_data, **kwargs)
analytics = Analytics()
+3
View File
@@ -1,4 +1,5 @@
"""Meet core API endpoints"""
from django.conf import settings
from django.core.exceptions import ValidationError
@@ -22,6 +23,8 @@ def exception_handler(exc, context):
detail = exc.message
elif hasattr(exc, "messages"):
detail = exc.messages
else:
detail = ""
exc = drf_exceptions.ValidationError(detail=detail)
+1
View File
@@ -1,4 +1,5 @@
"""Permission handlers for the Meet core app."""
from rest_framework import permissions
from ..models import RoleChoices
+1
View File
@@ -1,4 +1,5 @@
"""Client serializers for the Meet core app."""
from django.conf import settings
from django.utils.translation import gettext_lazy as _
+17
View File
@@ -1,4 +1,5 @@
"""API endpoints"""
import uuid
from django.conf import settings
@@ -19,6 +20,7 @@ from rest_framework import (
from core import models, utils
from ..analytics import analytics
from . import permissions, serializers
# pylint: disable=too-many-ancestors
@@ -184,6 +186,13 @@ class RoomViewSet(
"""
try:
instance = self.get_object()
analytics.track(
user=self.request.user,
event="Get Room",
properties={"slug": instance.slug},
)
except Http404:
if not settings.ALLOW_UNREGISTERED_ROOMS:
raise
@@ -232,6 +241,14 @@ class RoomViewSet(
role=models.RoleChoices.OWNER,
)
analytics.track(
user=self.request.user,
event="Create Room",
properties={
"slug": room.slug,
},
)
class ResourceAccessListModelMixin:
"""List mixin for resource access API."""
@@ -10,6 +10,8 @@ from mozilla_django_oidc.auth import (
from core.models import User
from ..analytics import analytics
class OIDCAuthenticationBackend(MozillaOIDCAuthenticationBackend):
"""Custom OpenID Connect (OIDC) Authentication Backend.
@@ -79,6 +81,7 @@ class OIDCAuthenticationBackend(MozillaOIDCAuthenticationBackend):
else:
user = None
analytics.identify(user=user)
return user
def create_user(self, claims):
+6
View File
@@ -22,6 +22,8 @@ from mozilla_django_oidc.views import (
OIDCLogoutView as MozillaOIDCOIDCLogoutView,
)
from ..analytics import analytics
class OIDCLogoutView(MozillaOIDCOIDCLogoutView):
"""Custom logout view for handling OpenID Connect (OIDC) logout flow.
@@ -98,6 +100,10 @@ class OIDCLogoutView(MozillaOIDCOIDCLogoutView):
logout_url = self.redirect_url
analytics.track(
user=request.user,
event="Signed Out",
)
if request.user.is_authenticated:
logout_url = self.construct_oidc_logout_url(request)
+1
View File
@@ -1,6 +1,7 @@
"""
Core application enums declaration
"""
from django.conf import global_settings, settings
from django.utils.translation import gettext_lazy as _
+1
View File
@@ -2,6 +2,7 @@
"""
Core application factories
"""
from django.conf import settings
from django.contrib.auth.hashers import make_password
from django.utils.text import slugify
+8
View File
@@ -1,6 +1,7 @@
"""
Declare and configure the models for the Meet core application
"""
import uuid
from logging import getLogger
@@ -162,6 +163,13 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
"""
return []
@property
def email_anonymized(self):
"""Anonymize the email address by replacing the local part with asterisks."""
if not self.email:
return ""
return f"***@{self.email.split('@')[1]}"
class Resource(BaseModel):
"""Model to define access control"""
@@ -92,9 +92,12 @@ def test_models_oidc_user_getter_invalid_token(django_assert_num_queries, monkey
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
with django_assert_num_queries(0), pytest.raises(
SuspiciousOperation,
match="User info contained no recognizable user identification",
with (
django_assert_num_queries(0),
pytest.raises(
SuspiciousOperation,
match="User info contained no recognizable user identification",
),
):
klass.get_or_create_user(access_token="test-token", id_token=None, payload=None)
+1
View File
@@ -1,4 +1,5 @@
"""Fixtures for tests in the Meet core application"""
from unittest import mock
import pytest
@@ -1,6 +1,7 @@
"""
Test rooms API endpoints in the Meet core app: create.
"""
import pytest
from rest_framework.test import APIClient
@@ -1,6 +1,7 @@
"""
Test rooms API endpoints in the Meet core app: delete.
"""
import pytest
from rest_framework.test import APIClient
@@ -1,6 +1,7 @@
"""
Test rooms API endpoints in the Meet core app: list.
"""
from unittest import mock
import pytest
@@ -1,6 +1,7 @@
"""
Test rooms API endpoints in the Meet core app: retrieve.
"""
import random
from unittest import mock
@@ -1,6 +1,7 @@
"""
Test rooms API endpoints in the Meet core app: update.
"""
import random
import pytest
@@ -1,6 +1,7 @@
"""
Test suite for generated openapi schema.
"""
import json
from io import StringIO
+132
View File
@@ -0,0 +1,132 @@
"""
Test for the Analytics class.
"""
# pylint: disable=W0212
from unittest.mock import patch
from django.contrib.auth.models import AnonymousUser
from django.test.utils import override_settings
import pytest
from core.analytics import Analytics
from core.factories import UserFactory
pytestmark = pytest.mark.django_db
@pytest.fixture(name="mock_june_analytics")
def _mock_june_analytics():
with patch("core.analytics.jAnalytics") as mock:
yield mock
@override_settings(ANALYTICS_KEY="test_key")
def test_analytics_init_enabled(mock_june_analytics):
"""Should enable analytics and set the write key correctly when ANALYTICS_KEY is set."""
analytics = Analytics()
assert analytics._enabled is True
assert mock_june_analytics.write_key == "test_key"
@override_settings(ANALYTICS_KEY=None)
def test_analytics_init_disabled():
"""Should disable analytics when ANALYTICS_KEY is not set."""
analytics = Analytics()
assert analytics._enabled is False
@override_settings(ANALYTICS_KEY="test_key")
def test_analytics_identify_user(mock_june_analytics):
"""Should identify a user with the correct traits when analytics is enabled."""
user = UserFactory(sub="12345", email="user@example.com")
analytics = Analytics()
analytics.identify(user)
mock_june_analytics.identify.assert_called_once_with(
user_id="12345", traits={"email": "***@example.com"}
)
@override_settings(ANALYTICS_KEY="test_key")
def test_analytics_identify_user_with_traits(mock_june_analytics):
"""Should identify a user with additional traits when analytics is enabled."""
user = UserFactory(sub="12345", email="user@example.com")
analytics = Analytics()
analytics.identify(user, traits={"email": "user@example.com", "foo": "foo"})
mock_june_analytics.identify.assert_called_once_with(
user_id="12345", traits={"email": "***@example.com", "foo": "foo"}
)
@override_settings(ANALYTICS_KEY=None)
def test_analytics_identify_not_enabled(mock_june_analytics):
"""Should not call identify when analytics is not enabled."""
user = UserFactory(sub="12345", email="user@example.com")
analytics = Analytics()
analytics.identify(user)
mock_june_analytics.identify.assert_not_called()
@override_settings(ANALYTICS_KEY="test_key")
def test_analytics_identify_no_user(mock_june_analytics):
"""Should not call identify when the user is None."""
analytics = Analytics()
analytics.identify(None)
mock_june_analytics.identify.assert_not_called()
@override_settings(ANALYTICS_KEY="test_key")
def test_analytics_identify_anonymous_user(mock_june_analytics):
"""Should not call identify when the user is anonymous."""
user = AnonymousUser()
analytics = Analytics()
analytics.identify(user)
mock_june_analytics.identify.assert_not_called()
@override_settings(ANALYTICS_KEY="test_key")
def test_analytics_track_event(mock_june_analytics):
"""Should track an event with the correct user and event details when analytics is enabled."""
user = UserFactory(sub="12345")
analytics = Analytics()
analytics.track(user, event="test_event", foo="foo")
mock_june_analytics.track.assert_called_once_with(
user_id="12345", event="test_event", foo="foo"
)
@override_settings(ANALYTICS_KEY=None)
def test_analytics_track_event_not_enabled(mock_june_analytics):
"""Should not call track when analytics is not enabled."""
user = UserFactory(sub="12345")
analytics = Analytics()
analytics.track(user, event="test_event", foo="foo")
mock_june_analytics.track.assert_not_called()
@override_settings(ANALYTICS_KEY="test_key")
@patch("uuid.uuid4", return_value="test_uuid4")
def test_analytics_track_event_no_user(mock_uuid4, mock_june_analytics):
"""Should track an event with a random anonymous user ID when the user is None."""
analytics = Analytics()
analytics.track(None, event="test_event", foo="foo")
mock_june_analytics.track.assert_called_once_with(
anonymous_id="test_uuid4", event="test_event", foo="foo"
)
mock_uuid4.assert_called_once()
@override_settings(ANALYTICS_KEY="test_key")
@patch("uuid.uuid4", return_value="test_uuid4")
def test_analytics_track_event_anonymous_user(mock_uuid4, mock_june_analytics):
"""Should track an event with a random anonymous user ID when the user is anonymous."""
user = AnonymousUser()
analytics = Analytics()
analytics.track(user, event="test_event", foo="foo")
mock_june_analytics.track.assert_called_once_with(
anonymous_id="test_uuid4", event="test_event", foo="foo"
)
mock_uuid4.assert_called_once()
@@ -1,6 +1,7 @@
"""
Test resource accesses API endpoints in the Meet core app.
"""
import random
from unittest import mock
from uuid import uuid4
+1
View File
@@ -1,6 +1,7 @@
"""
Test users API endpoints in the Meet core app.
"""
import pytest
from rest_framework.test import APIClient
@@ -1,6 +1,7 @@
"""
Unit tests for the ResourceAccess model with user
"""
from django.core.exceptions import ValidationError
import pytest
@@ -1,6 +1,7 @@
"""
Unit tests for the Room model
"""
from django.contrib.auth.models import AnonymousUser
from django.core.exceptions import ValidationError
@@ -1,6 +1,7 @@
"""
Unit tests for the User model
"""
from unittest import mock
from django.core.exceptions import ValidationError
@@ -43,3 +44,12 @@ def test_models_users_send_mail_main_missing():
user.email_user("my subject", "my message")
assert str(excinfo.value) == "User has no email address."
def test_models_users_email_anonymized():
"""The user's email should be anonymized if it exists."""
user = factories.UserFactory(email="john.doe@world.com")
assert user.email_anonymized == "***@world.com"
user = factories.UserFactory(email=None)
assert user.email_anonymized == ""
+1
View File
@@ -1,4 +1,5 @@
"""URL configuration for the core app."""
from django.conf import settings
from django.urls import include, path
+1
View File
@@ -1,6 +1,7 @@
"""
Utils functions used in the core app
"""
from typing import Optional
from uuid import uuid4
@@ -1,4 +1,5 @@
"""Management user to create a superuser."""
from django.contrib.auth import get_user_model
from django.core.management.base import BaseCommand
+1
View File
@@ -2,6 +2,7 @@
"""
meet's sandbox management script.
"""
import os
import sys
+1
View File
@@ -1,4 +1,5 @@
"""Meet celery configuration file."""
import os
from celery import Celery
+6
View File
@@ -9,6 +9,7 @@ https://docs.djangoproject.com/en/3.1/topics/settings/
For the full list of settings and their values, see
https://docs.djangoproject.com/en/3.1/ref/settings/
"""
import json
import os
@@ -364,6 +365,9 @@ class Base(Configuration):
ALLOW_UNREGISTERED_ROOMS = values.BooleanValue(
True, environ_name="ALLOW_UNREGISTERED_ROOMS", environ_prefix=None
)
ANALYTICS_KEY = values.Value(
None, environ_name="ANALYTICS_KEY", environ_prefix=None
)
# pylint: disable=invalid-name
@property
@@ -484,6 +488,8 @@ class Test(Base):
CELERY_TASK_ALWAYS_EAGER = values.BooleanValue(True)
ANALYTICS_KEY = None
def __init__(self):
# pylint: disable=invalid-name
self.INSTALLED_APPS += ["drf_spectacular_sidecar"]
+37 -36
View File
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "meet"
version = "0.1.2"
version = "0.1.3"
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
classifiers = [
"Development Status :: 5 - Production/Stable",
@@ -25,38 +25,39 @@ license = { file = "LICENSE" }
readme = "README.md"
requires-python = ">=3.10"
dependencies = [
"boto3==1.33.6",
"boto3==1.34.153",
"Brotli==1.1.0",
"celery[redis]==5.3.6",
"django-configurations==2.5",
"django-cors-headers==4.3.1",
"django-countries==7.5.1",
"celery[redis]==5.4.0",
"django-configurations==2.5.1",
"django-cors-headers==4.4.0",
"django-countries==7.6.1",
"django-parler==2.3",
"redis==5.0.3",
"redis==5.0.8",
"django-redis==5.4.0",
"django-storages[s3]==1.14.2",
"django-storages[s3]==1.14.4",
"django-timezone-field>=5.1",
"django==5.0.7",
"djangorestframework==3.15.2",
"drf_spectacular==0.26.5",
"dockerflow==2022.8.0",
"easy_thumbnails==2.8.5",
"drf_spectacular==0.27.2",
"dockerflow==2024.4.2",
"easy_thumbnails==2.9",
"factory_boy==3.3.0",
"freezegun==1.5.0",
"freezegun==1.5.1",
"gunicorn==22.0.0",
"jsonschema==4.20.0",
"markdown==3.5.1",
"jsonschema==4.23.0",
"june-analytics-python==2.3.0",
"markdown==3.6",
"nested-multipart-parser==1.5.0",
"psycopg[binary]==3.1.14",
"PyJWT==2.8.0",
"python-frontmatter==1.0.1",
"requests==2.32.2",
"sentry-sdk==2.8.0",
"psycopg[binary]==3.2.1",
"PyJWT==2.9.0",
"python-frontmatter==1.1.0",
"requests==2.32.3",
"sentry-sdk==2.12.0",
"url-normalize==1.4.3",
"WeasyPrint>=60.2",
"whitenoise==6.6.0",
"mozilla-django-oidc==4.0.0",
"livekit-api==0.5.1",
"whitenoise==6.7.0",
"mozilla-django-oidc==4.0.1",
"livekit-api==0.6.0",
]
[project.urls]
@@ -68,20 +69,20 @@ dependencies = [
[project.optional-dependencies]
dev = [
"django-extensions==3.2.3",
"drf-spectacular-sidecar==2023.12.1",
"drf-spectacular-sidecar==2024.7.1",
"ipdb==0.13.13",
"ipython==8.18.1",
"pyfakefs==5.3.2",
"ipython==8.26.0",
"pyfakefs==5.6.0",
"pylint-django==2.5.5",
"pylint==3.0.3",
"pytest-cov==4.1.0",
"pytest-django==4.7.0",
"pytest==7.4.3",
"pytest-icdiff==0.8",
"pytest-xdist==3.5.0",
"responses==0.24.1",
"ruff==0.1.6",
"types-requests==2.31.0.10",
"pylint==3.2.6",
"pytest-cov==5.0.0",
"pytest-django==4.8.0",
"pytest==8.3.2",
"pytest-icdiff==0.9",
"pytest-xdist==3.6.1",
"responses==0.25.3",
"ruff==0.5.6",
"types-requests==2.32.0.20240712",
]
[tool.setuptools]
@@ -100,7 +101,6 @@ exclude = [
"__pycache__",
"*/migrations/*",
]
ignore= ["DJ001", "PLR2004"]
line-length = 88
@@ -121,12 +121,13 @@ select = [
"SLF", # flake8-self
"T20", # flake8-print
]
ignore= ["DJ001", "PLR2004"]
[tool.ruff.lint.isort]
section-order = ["future","standard-library","django","third-party","meet","first-party","local-folder"]
sections = { meet=["core"], django=["django"] }
[tool.ruff.per-file-ignores]
[tool.ruff.lint.per-file-ignores]
"**/tests/*" = ["S", "SLF"]
[tool.pytest.ini_options]
-7
View File
@@ -1,7 +0,0 @@
#!/usr/bin/env python
"""Setup file for the meet module. All configuration stands in the setup.cfg file."""
# coding: utf-8
from setuptools import setup
setup()
+52 -5
View File
@@ -1,12 +1,12 @@
{
"name": "meet",
"version": "0.1.2",
"version": "0.1.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "meet",
"version": "0.1.2",
"version": "0.1.3",
"dependencies": {
"@livekit/components-react": "2.3.3",
"@livekit/components-styles": "1.0.12",
@@ -23,6 +23,7 @@
"react-aria-components": "1.2.1",
"react-dom": "18.2.0",
"react-i18next": "14.1.3",
"valtio": "1.13.2",
"wouter": "3.3.0"
},
"devDependencies": {
@@ -3643,13 +3644,13 @@
"version": "15.7.12",
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.12.tgz",
"integrity": "sha512-5zvhXYtRNRluoE/jAp4GVsSduVUzNWKkOZrCDBWYtE7biZywwdC2AcEzg+cSMLFRfVgeAFqpfNabiPjxFddV1Q==",
"dev": true
"devOptional": true
},
"node_modules/@types/react": {
"version": "18.3.3",
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.3.tgz",
"integrity": "sha512-hti/R0pS0q1/xx+TsI73XIqk26eBsISZ2R0wUijXIngRK9R/e7Xw/cXVxQK7R5JjW+SV4zGcn5hXjudkN/pLIw==",
"dev": true,
"devOptional": true,
"dependencies": {
"@types/prop-types": "*",
"csstype": "^3.0.2"
@@ -5134,7 +5135,7 @@
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
"dev": true
"devOptional": true
},
"node_modules/damerau-levenshtein": {
"version": "1.0.8",
@@ -5282,6 +5283,14 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/derive-valtio": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/derive-valtio/-/derive-valtio-0.1.0.tgz",
"integrity": "sha512-OCg2UsLbXK7GmmpzMXhYkdO64vhJ1ROUUGaTFyHjVwEdMEcTTRj7W1TxLbSBxdY8QLBPCcp66MTyaSy0RpO17A==",
"peerDependencies": {
"valtio": "*"
}
},
"node_modules/detect-libc": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-1.0.3.tgz",
@@ -8937,6 +8946,11 @@
"node": "10.* || >= 12.*"
}
},
"node_modules/proxy-compare": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/proxy-compare/-/proxy-compare-2.6.0.tgz",
"integrity": "sha512-8xuCeM3l8yqdmbPoYeLbrAXCBWu19XEYc5/F28f5qOaoAIMyfmBUkl5axiK+x9olUvRlcekvnm98AP9RDngOIw=="
},
"node_modules/punycode": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
@@ -10101,6 +10115,39 @@
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="
},
"node_modules/valtio": {
"version": "1.13.2",
"resolved": "https://registry.npmjs.org/valtio/-/valtio-1.13.2.tgz",
"integrity": "sha512-Qik0o+DSy741TmkqmRfjq+0xpZBXi/Y6+fXZLn0xNF1z/waFMbE3rkivv5Zcf9RrMUp6zswf2J7sbh2KBlba5A==",
"dependencies": {
"derive-valtio": "0.1.0",
"proxy-compare": "2.6.0",
"use-sync-external-store": "1.2.0"
},
"engines": {
"node": ">=12.20.0"
},
"peerDependencies": {
"@types/react": ">=16.8",
"react": ">=16.8"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"react": {
"optional": true
}
}
},
"node_modules/valtio/node_modules/use-sync-external-store": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.2.0.tgz",
"integrity": "sha512-eEgnFxGQ1Ife9bzYs6VLi8/4X6CObHMw9Qr9tPY43iKwsPw8xE8+EFsf/2cFZ5S3esXgpWgtSCtLNS41F+sKPA==",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0"
}
},
"node_modules/value-or-function": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/value-or-function/-/value-or-function-4.0.0.tgz",
+2 -1
View File
@@ -1,7 +1,7 @@
{
"name": "meet",
"private": true,
"version": "0.1.2",
"version": "0.1.3",
"type": "module",
"scripts": {
"dev": "panda codegen && vite",
@@ -26,6 +26,7 @@
"react-aria-components": "1.2.1",
"react-dom": "18.2.0",
"react-i18next": "14.1.3",
"valtio": "1.13.2",
"wouter": "3.3.0"
},
"devDependencies": {
+11 -6
View File
@@ -2,31 +2,36 @@ import '@livekit/components-styles'
import '@/styles/index.css'
import { Suspense } from 'react'
import { ReactQueryDevtools } from '@tanstack/react-query-devtools'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { QueryClientProvider } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import { useLang } from 'hoofd'
import { Switch, Route } from 'wouter'
import { NotFoundScreen } from './layout/NotFoundScreen'
import { RenderIfUserFetched } from './features/auth'
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";
const queryClient = new QueryClient()
function App() {
const { i18n } = useTranslation()
useLang(i18n.language)
const isProduction = import.meta.env.PROD
silenceLiveKitLogs(isProduction)
return (
<QueryClientProvider client={queryClient}>
<Suspense fallback={null}>
<RenderIfUserFetched>
<Layout>
<Switch>
{Object.entries(routes).map(([, route], i) => (
<Route key={i} path={route.path} component={route.Component} />
))}
<Route component={NotFoundScreen} />
</Switch>
</RenderIfUserFetched>
</Layout>
<ReactQueryDevtools initialIsOpen={false} />
</Suspense>
</QueryClientProvider>
+3
View File
@@ -0,0 +1,3 @@
import { QueryClient } from "@tanstack/react-query";
export const queryClient = new QueryClient()
@@ -0,0 +1,14 @@
import { Link } from '@/primitives'
import { AProps } from '@/primitives/A'
import { useTranslation } from 'react-i18next'
export const BackToHome = ({ size }: { size?: AProps['size'] }) => {
const { t } = useTranslation()
return (
<p>
<Link to="/" size={size}>
{t('backToHome')}
</Link>
</p>
)
}
@@ -0,0 +1,24 @@
import { useState, useEffect, type ReactNode } from 'react'
export const DelayedRender = ({
children,
delay = 500,
}: {
delay?: number
children: ReactNode
}) => {
const [show, setShow] = useState(false)
useEffect(() => {
if (delay === 0) {
setShow(true)
return
}
const timeout = setTimeout(() => setShow(true), delay)
return () => clearTimeout(timeout)
}, [delay])
if (delay !== 0 && !show) {
return null
}
return children
}
@@ -0,0 +1,25 @@
import { CenteredContent } from '@/layout/CenteredContent'
import { Screen } from '@/layout/Screen'
import { useTranslation } from 'react-i18next'
import { Center } from '@/styled-system/jsx'
import { Text } from '@/primitives'
export const ErrorScreen = ({ title, body }: {
title?: string
body?: string
}) => {
const { t } = useTranslation()
return (
<Screen layout="centered">
<CenteredContent title={title || t('error.heading')} withBackButton>
{!!body && (
<Center>
<Text as="p" variant="h3" centered>
{body}
</Text>
</Center>
)}
</CenteredContent>
</Screen>
)
}
@@ -0,0 +1,27 @@
import { Screen, type ScreenProps } from '@/layout/Screen'
import { DelayedRender } from './DelayedRender'
import { CenteredContent } from '@/layout/CenteredContent'
import { useTranslation } from 'react-i18next'
import { Center } from '@/styled-system/jsx'
export const LoadingScreen = ({
delay = 500,
header = undefined,
layout = 'centered',
}: {
delay?: number
} & Omit<ScreenProps, 'children'>) => {
const { t } = useTranslation()
return (
<DelayedRender delay={delay}>
<Screen layout={layout} header={header}>
<CenteredContent>
<Center>
<p>{t('loading')}</p>
</Center>
</CenteredContent>
</Screen>
</DelayedRender>
)
}
@@ -0,0 +1,12 @@
import { CenteredContent } from '@/layout/CenteredContent'
import { Screen } from '@/layout/Screen'
import { useTranslation } from 'react-i18next'
export const NotFoundScreen = () => {
const { t } = useTranslation()
return (
<Screen layout="centered">
<CenteredContent title={t('notFound.heading')} withBackButton />
</Screen>
)
}
@@ -0,0 +1,28 @@
import { ErrorScreen } from '@/components/ErrorScreen'
import { LoadingScreen } from '@/components/LoadingScreen'
/**
* Render an error or loading Screen while a given `status` is not a success,
* otherwise directly render children.
*
* `status` matches react query statuses.
*
* Children usually contain a Screen at some point in the render tree.
*/
export const QueryAware = ({
status,
children,
}: {
status: 'error' | 'idle' | 'pending' | 'success'
children: React.ReactNode
}) => {
if (status === 'error') {
return <ErrorScreen />
}
if (status === 'pending') {
return <LoadingScreen header={undefined} />
}
return children
}
@@ -12,6 +12,7 @@ export const useUser = () => {
const query = useQuery({
queryKey: [keys.user],
queryFn: fetchUser,
staleTime: 1000 * 60 * 60, // 1 hour
})
const isLoggedIn =
@@ -1,17 +0,0 @@
import { type ReactNode } from 'react'
import { useUser } from '@/features/auth'
import { LoadingScreen } from '@/layout/LoadingScreen'
/**
* wrapper that renders children only when user info has been actually fetched
*
* this is helpful to prevent flash of logged-out content for a few milliseconds when user is actually logged in
*/
export const RenderIfUserFetched = ({ children }: { children: ReactNode }) => {
const { isLoggedIn } = useUser()
return isLoggedIn !== undefined ? (
children
) : (
<LoadingScreen renderTimeout={1000} />
)
}
@@ -0,0 +1,20 @@
import { useUser } from '@/features/auth'
import { LoadingScreen } from '@/components/LoadingScreen'
/**
* Renders a loading Screen while user info has not been fetched yet,
* otherwise directly render children.
*
* Children usually contain a Screen at some point in the render tree.
*
* This is helpful to prevent flash of logged-out content for a few milliseconds when user is actually logged in
*/
export const UserAware = ({ children }: { children: React.ReactNode }) => {
const { isLoggedIn } = useUser()
return isLoggedIn !== undefined ? (
children
) : (
<LoadingScreen header={false} delay={1000} />
)
}
+1 -1
View File
@@ -1,4 +1,4 @@
export { useUser } from './api/useUser'
export { authUrl } from './utils/authUrl'
export { logoutUrl } from './utils/logoutUrl'
export { RenderIfUserFetched } from './components/RenderIfUserFetched'
export { UserAware } from './components/UserAware'
+24 -13
View File
@@ -1,20 +1,31 @@
import { useTranslation } from 'react-i18next'
import { DialogTrigger } from 'react-aria-components'
import { Button, Div, Text, VerticallyOffCenter } from '@/primitives'
import { Button, Text } from '@/primitives'
import { HStack } from '@/styled-system/jsx'
import { navigateTo } from '@/navigation/navigateTo'
import { generateRoomId } from '@/features/rooms'
import { authUrl, useUser } from '@/features/auth'
import { Screen } from '@/layout/Screen'
import { Centered } from '@/layout/Centered'
import { generateRoomId } from '@/features/rooms'
import { authUrl, useUser, UserAware } from '@/features/auth'
import { JoinMeetingDialog } from '../components/JoinMeetingDialog'
import { useCreateRoom } from '@/features/rooms'
export const Home = () => {
const { t } = useTranslation('home')
const { isLoggedIn } = useUser()
const { mutateAsync: createRoom } = useCreateRoom({
onSuccess: (data) => {
navigateTo('room', data.slug, {
state: { create: true, initialRoomData: data },
})
}
});
return (
<Screen>
<VerticallyOffCenter>
<Div margin="auto" width="fit-content">
<UserAware>
<Screen>
<Centered width="fit-content">
<Text as="h1" variant="display">
{t('heading')}
</Text>
@@ -31,10 +42,10 @@ export const Home = () => {
variant="primary"
onPress={
isLoggedIn
? () =>
navigateTo('room', generateRoomId(), {
state: { create: true },
})
? async () => {
const slug = generateRoomId()
await createRoom({slug})
}
: undefined
}
href={isLoggedIn ? undefined : authUrl()}
@@ -49,8 +60,8 @@ export const Home = () => {
<JoinMeetingDialog />
</DialogTrigger>
</HStack>
</Div>
</VerticallyOffCenter>
</Screen>
</Centered>
</Screen>
</UserAware>
)
}
@@ -8,4 +8,7 @@ export type ApiRoom = {
room: string
token: string
}
configuration?: {
[key: string]: string | number | boolean;
}
}
@@ -0,0 +1,25 @@
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;
}
const createRoom = ({slug}: CreateRoomParams): Promise<ApiRoom> => {
return fetchApi(`rooms/`, {
method: 'POST',
body: JSON.stringify({
name: slug,
}),
})
};
export function useCreateRoom(options?: UseMutationOptions<ApiRoom, ApiError, CreateRoomParams>) {
return useMutation<ApiRoom, ApiError, CreateRoomParams>({
mutationFn: createRoom,
onSuccess: options?.onSuccess,
});
}
@@ -1,33 +1,57 @@
import { useMemo, useState } from 'react'
import { useEffect, useMemo, useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import {
formatChatMessageLinks,
LiveKitRoom,
VideoConference,
type LocalUserChoices,
} from '@livekit/components-react'
import { Room, RoomOptions } from 'livekit-client'
import { keys } from '@/api/queryKeys'
import { queryClient } from '@/api/queryClient'
import { navigateTo } from '@/navigation/navigateTo'
import { QueryAware } from '@/layout/QueryAware'
import { Screen } from '@/layout/Screen'
import { QueryAware } from '@/components/QueryAware'
import { ErrorScreen } from '@/components/ErrorScreen'
import { fetchRoom } from '../api/fetchRoom'
import { ApiRoom } from '../api/ApiRoom'
import { useCreateRoom } from '../api/createRoom'
import { InviteDialog } from './InviteDialog'
export const Conference = ({
roomId,
userConfig,
initialRoomData,
mode = 'join',
}: {
roomId: string
userConfig: LocalUserChoices
mode?: 'join' | 'create'
initialRoomData?: ApiRoom
}) => {
const { status, data } = useQuery({
queryKey: [keys.room, roomId, userConfig.username],
const fetchKey = [keys.room, roomId, userConfig.username]
const { mutateAsync: createRoom, status: createStatus, isError: isCreateError} = useCreateRoom({
onSuccess: (data) => {
queryClient.setQueryData(fetchKey, data)
},
});
const { status: fetchStatus, isError: isFetchError, data } = useQuery({
queryKey: fetchKey,
enabled: !initialRoomData,
initialData: initialRoomData,
queryFn: () =>
fetchRoom({
roomId: roomId as string,
username: userConfig.username,
}).catch((error) => {
if (error.statusCode == '404') {
createRoom({slug: roomId})
}
}),
retry: false,
})
const roomOptions = useMemo((): RoomOptions => {
@@ -46,29 +70,55 @@ export const Conference = ({
const [showInviteDialog, setShowInviteDialog] = useState(mode === 'create')
/**
* checks for actual click on the leave button instead of
* relying on LiveKitRoom onDisconnected because onDisconnected
* triggers even on page reload, it's not a user "onLeave" event really.
* Here we want to react to the user actually deciding to leave.
*/
useEffect(() => {
const checkOnLeaveClick = (event: MouseEvent) => {
const target = event.target as HTMLElement
if (target.classList.contains('lk-disconnect-button')) {
navigateTo('feedback')
}
}
document.body.addEventListener('click', checkOnLeaveClick)
return () => {
document.body.removeEventListener('click', checkOnLeaveClick)
}
}, [])
const { t } = useTranslation('rooms')
if (isCreateError) {
// this error screen should be replaced by a proper waiting room for anonymous user.
return <ErrorScreen title={t('error.createRoom.heading')} body={t('error.createRoom.body')} />
}
return (
<QueryAware status={status}>
<LiveKitRoom
room={room}
serverUrl={data?.livekit?.url}
token={data?.livekit?.token}
connect={true}
audio={userConfig.audioEnabled}
video={userConfig.videoEnabled}
onDisconnected={() => {
navigateTo('feedback')
}}
>
<VideoConference />
{showInviteDialog && (
<InviteDialog
isOpen={showInviteDialog}
onOpenChange={setShowInviteDialog}
roomId={roomId}
onClose={() => setShowInviteDialog(false)}
<QueryAware status={isFetchError ? createStatus : fetchStatus}>
<Screen>
<LiveKitRoom
room={room}
serverUrl={data?.livekit?.url}
token={data?.livekit?.token}
connect={true}
audio={userConfig.audioEnabled}
video={userConfig.videoEnabled}
>
<VideoConference
chatMessageFormatter={formatChatMessageLinks}
/>
)}
</LiveKitRoom>
{showInviteDialog && (
<InviteDialog
isOpen={showInviteDialog}
onOpenChange={setShowInviteDialog}
roomId={roomId}
onClose={() => setShowInviteDialog(false)}
/>
)}
</LiveKitRoom>
</Screen>
</QueryAware>
)
}
@@ -1,6 +1,7 @@
import { useTranslation } from 'react-i18next'
import { Box } from '@/layout/Box'
import { PreJoin, type LocalUserChoices } from '@livekit/components-react'
import { Screen } from '@/layout/Screen'
import { CenteredContent } from '@/layout/CenteredContent'
export const Join = ({
onSubmit,
@@ -10,15 +11,17 @@ export const Join = ({
const { t } = useTranslation('rooms')
return (
<Box title={t('join.heading')} withBackButton>
<PreJoin
persistUserChoices
onSubmit={onSubmit}
micLabel={t('join.micLabel')}
camLabel={t('join.camlabel')}
joinLabel={t('join.joinLabel')}
userLabel={t('join.userLabel')}
/>
</Box>
<Screen layout="centered">
<CenteredContent title={t('join.heading')}>
<PreJoin
persistUserChoices
onSubmit={onSubmit}
micLabel={t('join.micLabel')}
camLabel={t('join.camlabel')}
joinLabel={t('join.joinLabel')}
userLabel={t('join.userLabel')}
/>
</CenteredContent>
</Screen>
)
}
+1
View File
@@ -2,3 +2,4 @@ export { Room as RoomRoute } from './routes/Room'
export { FeedbackRoute } from './routes/Feedback'
export { roomIdPattern, isRoomValid } from './utils/isRoomValid'
export { generateRoomId } from './utils/generateRoomId'
export { useCreateRoom } from './api/createRoom'
@@ -1,19 +1,15 @@
import { useTranslation } from 'react-i18next'
import { BoxScreen } from '@/layout/BoxScreen'
import { Div, Link, P } from '@/primitives'
import { Text } from '@/primitives'
import { Screen } from '@/layout/Screen'
import { CenteredContent } from '@/layout/CenteredContent'
export const FeedbackRoute = () => {
const { t } = useTranslation('rooms')
return (
<BoxScreen title={t('feedback.heading')}>
<Div textAlign="left">
<P>{t('feedback.body')}</P>
</Div>
<Div marginTop={1}>
<P>
<Link to="/">{t('backToHome', { ns: 'global' })}</Link>
</P>
</Div>
</BoxScreen>
<Screen layout="centered">
<CenteredContent title={t('feedback.heading')} withBackButton>
<Text as="p" variant="h3" centered>{t('feedback.body')}</Text>
</CenteredContent>
</Screen>
)
}
+17 -15
View File
@@ -4,18 +4,18 @@ import {
type LocalUserChoices,
} from '@livekit/components-react'
import { useParams } from 'wouter'
import { Screen } from '@/layout/Screen'
import { ErrorScreen } from '@/layout/ErrorScreen'
import { useUser } from '@/features/auth'
import { ErrorScreen } from '@/components/ErrorScreen'
import { useUser, UserAware } from '@/features/auth'
import { Conference } from '../components/Conference'
import { Join } from '../components/Join'
export const Room = () => {
const { user, isLoggedIn } = useUser()
const { isLoggedIn } = useUser()
const { userChoices: existingUserChoices } = usePersistentUserChoices()
const [userConfig, setUserConfig] = useState<LocalUserChoices | null>(null)
const { roomId } = useParams()
const initialRoomData = history.state?.initialRoomData
const mode = isLoggedIn && history.state?.create ? 'create' : 'join'
const skipJoinScreen = isLoggedIn && mode === 'create'
@@ -25,21 +25,23 @@ export const Room = () => {
if (!userConfig && !skipJoinScreen) {
return (
<Screen>
<UserAware>
<Join onSubmit={setUserConfig} />
</Screen>
</UserAware>
)
}
return (
<Conference
roomId={roomId}
mode={mode}
userConfig={{
...existingUserChoices,
...(skipJoinScreen ? { username: user?.email as string } : {}),
...userConfig,
}}
/>
<UserAware>
<Conference
initialRoomData={initialRoomData}
roomId={roomId}
mode={mode}
userConfig={{
...existingUserChoices,
...userConfig,
}}
/>
</UserAware>
)
}
+4 -3
View File
@@ -1,6 +1,6 @@
import { useTranslation } from 'react-i18next'
const langageLabels: Record<string, string> = {
const languageLabels: Record<string, string> = {
en: 'English',
fr: 'Français',
de: 'Deutsch',
@@ -13,14 +13,15 @@ export const useLanguageLabels = () => {
(lang) => lang !== 'cimode'
)
const languagesList = supportedLanguages.map((lang) => ({
key: lang,
value: lang,
label: langageLabels[lang],
label: languageLabels[lang],
}))
return {
languagesList,
currentLanguage: {
key: i18n.language,
label: langageLabels[i18n.language],
label: languageLabels[i18n.language],
},
}
}
-32
View File
@@ -1,32 +0,0 @@
import type { ReactNode } from 'react'
import { useTranslation } from 'react-i18next'
import { Box as BoxDiv, H, Link, VerticallyOffCenter } from '@/primitives'
export type BoxProps = {
children?: ReactNode
title?: ReactNode
withBackButton?: boolean
}
export const Box = ({
children,
title = '',
withBackButton = false,
}: BoxProps) => {
const { t } = useTranslation()
return (
<VerticallyOffCenter>
<BoxDiv type="screen">
{!!title && <H lvl={1}>{title}</H>}
{children}
{!!withBackButton && (
<p>
<Link to="/" size="sm">
{t('backToHome')}
</Link>
</p>
)}
</BoxDiv>
</VerticallyOffCenter>
)
}
-10
View File
@@ -1,10 +0,0 @@
import { Screen } from './Screen'
import { Box, type BoxProps } from './Box'
export const BoxScreen = (props: BoxProps) => {
return (
<Screen>
<Box {...props} />
</Screen>
)
}
+19
View File
@@ -0,0 +1,19 @@
import type { ReactNode } from 'react'
import { Div, VerticallyOffCenter } from '@/primitives'
import type { SystemStyleObject } from '../styled-system/types'
export const Centered = ({
width = '38rem',
children,
}: {
width?: SystemStyleObject['width']
children?: ReactNode
}) => {
return (
<VerticallyOffCenter>
<Div margin="auto" width={width} maxWidth="100%">
{children}
</Div>
</VerticallyOffCenter>
)
}
@@ -0,0 +1,29 @@
import { BackToHome } from '@/components/BackToHome'
import { H } from '@/primitives'
import { Center } from '@/styled-system/jsx'
export const CenteredContent = ({
title,
children,
withBackButton,
}: {
title?: string
children?: React.ReactNode
withBackButton?: boolean
}) => {
return (
<>
{!!title && (
<Center>
<H lvl={1}>{title}</H>
</Center>
)}
{children}
{!!withBackButton && (
<Center marginTop={2}>
<BackToHome size="sm" />
</Center>
)}
</>
)
}
-7
View File
@@ -1,7 +0,0 @@
import { BoxScreen } from './BoxScreen'
import { useTranslation } from 'react-i18next'
export const ErrorScreen = () => {
const { t } = useTranslation()
return <BoxScreen title={t('error.heading')} withBackButton />
}
@@ -1,7 +0,0 @@
import { BoxScreen } from './BoxScreen'
import { useTranslation } from 'react-i18next'
export const ForbiddenScreen = () => {
const { t } = useTranslation()
return <BoxScreen title={t('forbidden.heading')} withBackButton />
}
+1 -1
View File
@@ -74,7 +74,7 @@ export const Header = () => {
{user.email}
</Button>
<PopoverList
items={[{ value: 'logout', label: t('logout') }]}
items={[{ key: 'logout', value: 'logout', label: t('logout') }]}
onAction={(value) => {
if (value === 'logout') {
window.location.href = logoutUrl()
+42
View File
@@ -0,0 +1,42 @@
import { type ReactNode } from 'react'
import { css } from '@/styled-system/css'
import { Header } from './Header'
import { layoutStore } from '@/stores/layout'
import { useSnapshot } from 'valtio'
export type Layout = 'fullpage' | 'centered'
/**
* Layout component for the app.
*
* This component is meant to be used as a wrapper around the whole app.
* In a specific page, use the `Screen` component and change its props to change global page layout.
*/
export const Layout = ({ children }: { children: ReactNode }) => {
const layoutSnap = useSnapshot(layoutStore)
const showHeader = layoutSnap.showHeader
return (
<div
className={css({
height: '100%',
display: 'flex',
flexDirection: 'column',
backgroundColor: 'white',
color: 'default.text',
})}
>
{showHeader && <Header />}
<main
className={css({
flexGrow: 1,
overflow: 'auto',
display: 'flex',
flexDirection: 'column',
})}
>
{children}
</main>
</div>
)
}
-35
View File
@@ -1,35 +0,0 @@
import { useState, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { BoxScreen } from './BoxScreen'
import { Screen } from './Screen'
import { VerticallyOffCenter } from '@/primitives'
import { Center } from '@/styled-system/jsx'
export const LoadingScreen = ({
asBox = false,
renderTimeout = 500,
}: {
asBox?: boolean
renderTimeout?: number
}) => {
const { t } = useTranslation()
// show the loading screen only after a little while to prevent flash of texts
const [show, setShow] = useState(false)
useEffect(() => {
const timeout = setTimeout(() => setShow(true), renderTimeout)
return () => clearTimeout(timeout)
}, [renderTimeout])
if (!show) {
return null
}
const Container = asBox ? BoxScreen : Screen
return (
<Container>
<VerticallyOffCenter>
<Center>
<p>{t('loading')}</p>
</Center>
</VerticallyOffCenter>
</Container>
)
}
@@ -1,7 +0,0 @@
import { useTranslation } from 'react-i18next'
import { BoxScreen } from './BoxScreen'
export const NotFoundScreen = () => {
const { t } = useTranslation()
return <BoxScreen title={t('notFound.heading')} withBackButton />
}
-21
View File
@@ -1,21 +0,0 @@
import { ErrorScreen } from './ErrorScreen'
import { LoadingScreen } from './LoadingScreen'
import { Screen } from './Screen'
export const QueryAware = ({
status,
children,
}: {
status: 'error' | 'pending' | 'success'
children: React.ReactNode
}) => {
if (status === 'error') {
return <ErrorScreen />
}
if (status === 'pending') {
return <LoadingScreen />
}
return <Screen>{children}</Screen>
}
+26 -31
View File
@@ -1,35 +1,30 @@
import type { ReactNode } from 'react'
import { css } from '@/styled-system/css'
import { Header } from './Header'
import { layoutStore } from '@/stores/layout'
import { Layout } from './Layout'
import { useEffect } from 'react'
import { Centered } from './Centered'
export type ScreenProps = {
/**
* 'fullpage' by default.
*/
layout?: Layout
/**
* Show header or not.
* True by default. Pass undefined to render the screen without modifying current header visibility
*/
header?: boolean
children: React.ReactNode
}
export const Screen = ({
type,
layout = 'fullpage',
header = true,
children,
}: {
type?: 'splash'
children?: ReactNode
}) => {
return (
<div
className={css({
height: '100%',
display: 'flex',
flexDirection: 'column',
backgroundColor: type === 'splash' ? 'white' : 'default.bg',
color: 'default.text',
})}
>
{type !== 'splash' && <Header />}
<main
className={css({
flexGrow: 1,
overflow: 'auto',
display: 'flex',
flexDirection: 'column',
})}
>
{children}
</main>
</div>
)
}: ScreenProps) => {
useEffect(() => {
if (header !== undefined) {
layoutStore.showHeader = header
}
}, [header])
return layout === 'centered' ? <Centered>{children}</Centered> : children
}
+6
View File
@@ -16,5 +16,11 @@
"copy": "",
"heading": "",
"inputLabel": ""
},
"error": {
"createRoom": {
"heading": "",
"body": ""
}
}
}
+6
View File
@@ -16,5 +16,11 @@
"copy": "Copy",
"heading": "Share the meeting link",
"inputLabel": "Meeting link"
},
"error": {
"createRoom": {
"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."
}
}
}
+6
View File
@@ -16,5 +16,11 @@
"copy": "Copier le lien",
"heading": "Partager le lien vers la réunion",
"inputLabel": "Lien vers la réunion"
},
"error": {
"createRoom": {
"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."
}
}
}
+3 -2
View File
@@ -42,7 +42,7 @@ export const PopoverList = <T extends string | number = string>({
}: {
closeOnAction?: boolean
onAction: (key: T) => void
items: Array<string | { value: T; label: ReactNode }>
items: Array<string | { key: string, value: T; label: ReactNode }>
} & ButtonProps) => {
const popoverState = useContext(OverlayTriggerStateContext)!
return (
@@ -50,8 +50,9 @@ export const PopoverList = <T extends string | number = string>({
{items.map((item) => {
const value = typeof item === 'string' ? item : item.value
const label = typeof item === 'string' ? item : item.label
const key = typeof item === 'string' ? item : item.key
return (
<li>
<li key={key}>
<ListItem
key={value}
onPress={() => {
+8
View File
@@ -45,6 +45,14 @@ export const text = cva({
},
inherits: {},
},
centered: {
true: {
textAlign: 'center',
},
false: {
textAlign: 'inherit',
},
},
bold: {
true: {
fontWeight: 'bold',
+9
View File
@@ -0,0 +1,9 @@
import { proxy } from 'valtio'
type State = {
showHeader: boolean
}
export const layoutStore = proxy<State>({
showHeader: false,
})
+1
View File
@@ -145,6 +145,7 @@
[data-lk-theme] .lk-prejoin {
padding-top: 0;
width: 100%;
}
[data-lk-theme] .lk-participant-tile {
+6
View File
@@ -0,0 +1,6 @@
import { LogLevel, setLogLevel } from "livekit-client";
export const silenceLiveKitLogs = (shouldSilenceLogs: boolean) => {
setLogLevel(shouldSilenceLogs ? LogLevel.silent : LogLevel.debug);
}
@@ -46,6 +46,8 @@ backend:
{{- end }}
{{- end }}
LIVEKIT_API_URL: https://livekit.127.0.0.1.nip.io/
ANALYTICS_KEY: xwhoIMCZ8PBRjQ2t
ALLOW_UNREGISTERED_ROOMS: False
migrate:
@@ -1,7 +1,7 @@
image:
repository: lasuite/meet-backend
pullPolicy: Always
tag: "v0.1.2"
tag: "v0.1.3"
backend:
migrateJobAnnotations:
@@ -93,6 +93,7 @@ backend:
name: backend
key: LIVEKIT_API_KEY
LIVEKIT_API_URL: https://livekit-preprod.beta.numerique.gouv.fr
ALLOW_UNREGISTERED_ROOMS: False
createsuperuser:
command:
@@ -106,7 +107,7 @@ frontend:
image:
repository: lasuite/meet-frontend
pullPolicy: Always
tag: "v0.1.2"
tag: "v0.1.3"
ingress:
enabled: true
@@ -1,7 +1,7 @@
image:
repository: lasuite/meet-backend
pullPolicy: Always
tag: "v0.1.2"
tag: "v0.1.3"
backend:
migrateJobAnnotations:
@@ -93,6 +93,8 @@ backend:
name: backend
key: LIVEKIT_API_KEY
LIVEKIT_API_URL: https://livekit-preprod.beta.numerique.gouv.fr
ANALYTICS_KEY: mwuxTKi8o2xzWH54
ALLOW_UNREGISTERED_ROOMS: False
createsuperuser:
command:
@@ -106,7 +108,7 @@ frontend:
image:
repository: lasuite/meet-frontend
pullPolicy: Always
tag: "v0.1.2"
tag: "v0.1.3"
ingress:
enabled: true
@@ -93,6 +93,8 @@ backend:
name: backend
key: LIVEKIT_API_KEY
LIVEKIT_API_URL: https://livekit-preprod.beta.numerique.gouv.fr
ANALYTICS_KEY: Roi1k6IAc2DEqHB0
ALLOW_UNREGISTERED_ROOMS: False
createsuperuser:
command:
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "mail_mjml",
"version": "0.1.2",
"version": "0.1.3",
"description": "An util to generate html and text django's templates from mjml templates",
"type": "module",
"dependencies": {