Compare commits

..

30 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
79 changed files with 1775 additions and 1107 deletions
-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 "$@"
-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
+1 -1
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",
+2 -2
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",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "meet",
"private": true,
"version": "0.1.3",
"version": "0.1.4",
"type": "module",
"scripts": {
"dev": "panda codegen && vite",
-1
View File
@@ -199,7 +199,6 @@ const config: Config = {
text: { value: '{colors.white}' },
subtle: { value: '{colors.red.100}' },
'subtle-text': { value: '{colors.red.700}' },
...pandaPreset.theme.tokens.colors.red,
},
success: {
DEFAULT: { value: '{colors.green.700}' },
+9 -12
View File
@@ -6,7 +6,6 @@ import { QueryClientProvider } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import { useLang } from 'hoofd'
import { Switch, Route } from 'wouter'
import { I18nProvider } from 'react-aria-components'
import { Layout } from './layout/Layout'
import { NotFoundScreen } from './components/NotFoundScreen'
import { routes } from './routes'
@@ -24,17 +23,15 @@ function App() {
return (
<QueryClientProvider client={queryClient}>
<Suspense fallback={null}>
<I18nProvider locale={i18n.language}>
<Layout>
<Switch>
{Object.entries(routes).map(([, route], i) => (
<Route key={i} path={route.path} component={route.Component} />
))}
<Route component={NotFoundScreen} />
</Switch>
</Layout>
<ReactQueryDevtools initialIsOpen={false} />
</I18nProvider>
<Layout>
<Switch>
{Object.entries(routes).map(([, route], i) => (
<Route key={i} path={route.path} component={route.Component} />
))}
<Route component={NotFoundScreen} />
</Switch>
</Layout>
<ReactQueryDevtools initialIsOpen={false} />
</Suspense>
</QueryClientProvider>
)
@@ -1,2 +0,0 @@
export { useAudioOutputs } from './utils/useAudioOutputs'
export { usePersistedMediaDeviceSelect } from './utils/usePersistedMediaDeviceSelect'
@@ -1,36 +0,0 @@
import { useState, useEffect } from 'react'
const getOutputDevices = () => {
return navigator.mediaDevices
.getUserMedia({ audio: true, video: false })
.then(() => navigator.mediaDevices.enumerateDevices())
.then((devices) => devices.filter(({ kind }) => kind === 'audiooutput'))
}
/**
* custom hook to fetch audio outputs
*
* this is used instead of livekit's useMediaDevices because the livekit integrated one seems to request
* outputs in a weird order, resulting in empty results in firefox if we didn't ask for input before
*/
export const useAudioOutputs = () => {
const [audioOutputs, setAudioOutputs] = useState<MediaDeviceInfo[]>([])
useEffect(() => {
const retrieveOutputDevices = () => {
getOutputDevices()
.then(setAudioOutputs)
.catch((error) => {
console.error('Audio outputs retrieval error :', error)
})
}
retrieveOutputDevices()
const onDeviceChange = () => {
retrieveOutputDevices()
}
navigator?.mediaDevices?.addEventListener('devicechange', onDeviceChange)
return () => {
navigator.mediaDevices.removeEventListener('devicechange', onDeviceChange)
}
}, [])
return audioOutputs
}
@@ -1,27 +0,0 @@
import { useMediaDeviceSelect } from '@livekit/components-react'
import { settingsStore } from '@/features/settings'
/**
* wrap livekit's useMediaDeviceSelect to automatically save in our devices state user selection
*
* note: audiooutput devices are not handled here as we dont use useMediaDeviceSelect for them
*/
export const usePersistedMediaDeviceSelect = (
...args: Parameters<typeof useMediaDeviceSelect>
): ReturnType<typeof useMediaDeviceSelect> => {
const results = useMediaDeviceSelect(...args)
const originalSetter = results.setActiveMediaDevice
results.setActiveMediaDevice = (
...activeMediaDeviceArgs: Parameters<typeof results.setActiveMediaDevice>
) => {
const id = activeMediaDeviceArgs[0]
if (args[0].kind === 'audioinput') {
settingsStore.devices.micDeviceId = id
}
if (args[0].kind === 'videoinput') {
settingsStore.devices.cameraDeviceId = id
}
return originalSetter(...activeMediaDeviceArgs)
}
return results
}
@@ -9,11 +9,16 @@ 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, {
@@ -44,7 +49,7 @@ export const Home = () => {
isLoggedIn
? async () => {
const slug = generateRoomId()
await createRoom({ slug })
await createRoom({ slug, username })
}
: undefined
}
@@ -5,10 +5,14 @@ import { ApiRoom } from './ApiRoom'
export interface CreateRoomParams {
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,
@@ -4,7 +4,7 @@ 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'
@@ -17,7 +17,8 @@ import { fetchRoom } from '../api/fetchRoom'
import { ApiRoom } from '../api/ApiRoom'
import { useCreateRoom } from '../api/createRoom'
import { InviteDialog } from './InviteDialog'
import { type SettingsState } from '@/features/settings'
import { VideoConference } from '../livekit/prefabs/VideoConference'
export const Conference = ({
roomId,
@@ -26,10 +27,7 @@ export const Conference = ({
mode = 'join',
}: {
roomId: string
userConfig: {
devices: SettingsState['devices']
username: SettingsState['username']
}
userConfig: LocalUserChoices
mode?: 'join' | 'create'
initialRoomData?: ApiRoom
}) => {
@@ -51,6 +49,7 @@ export const Conference = ({
data,
} = useQuery({
queryKey: fetchKey,
staleTime: 6 * 60 * 60 * 1000, // By default, LiveKit access tokens expire 6 hours after generation
enabled: !initialRoomData,
initialData: initialRoomData,
queryFn: () =>
@@ -59,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,
@@ -68,21 +67,14 @@ export const Conference = ({
const roomOptions = useMemo((): RoomOptions => {
return {
videoCaptureDefaults: {
deviceId: userConfig.devices.cameraDeviceId ?? undefined,
deviceId: userConfig.videoDeviceId ?? undefined,
},
audioCaptureDefaults: {
deviceId: userConfig.devices.micDeviceId ?? undefined,
},
audioOutput: {
deviceId: userConfig.devices.speakerDeviceId ?? undefined,
deviceId: userConfig.audioDeviceId ?? undefined,
},
}
// do not rely on the userConfig object directly as its reference may change on every render
}, [
userConfig.devices.cameraDeviceId,
userConfig.devices.micDeviceId,
userConfig.devices.speakerDeviceId,
])
}, [userConfig.videoDeviceId, userConfig.audioDeviceId])
const room = useMemo(() => new Room(roomOptions), [roomOptions])
@@ -120,14 +112,14 @@ export const Conference = ({
return (
<QueryAware status={isFetchError ? createStatus : fetchStatus}>
<Screen>
<Screen header={false}>
<LiveKitRoom
room={room}
serverUrl={data?.livekit?.url}
token={data?.livekit?.token}
connect={true}
audio={userConfig.devices.enableMic}
video={userConfig.devices.enableCamera}
audio={userConfig.audioEnabled}
video={userConfig.videoEnabled}
>
<VideoConference chatMessageFormatter={formatChatMessageLinks} />
{showInviteDialog && (
@@ -1,358 +0,0 @@
import { Screen } from '@/layout/Screen'
import {
Button,
Div,
Field,
Form,
H,
Menu,
MenuList,
ToggleButton,
VerticallyOffCenter,
} from '@/primitives'
import { Center, HStack, VStack } from '@/styled-system/jsx'
import {
RiArrowDropDownLine,
RiMicLine,
RiMicOffLine,
RiVideoOffLine,
RiVideoOnLine,
RiVolumeUpLine,
} from '@remixicon/react'
import {
useMaybeRoomContext,
usePreviewTracks,
} from '@livekit/components-react'
import { Track, LocalVideoTrack, LocalAudioTrack } from 'livekit-client'
import { useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useSnapshot } from 'valtio'
import {
usePersistedMediaDeviceSelect,
useAudioOutputs,
} from '@/features/devices'
import { settingsStore, type SettingsState } from '@/features/settings'
import { css } from '@/styled-system/css'
export const HomemadeJoin = ({
onSubmit,
}: {
onSubmit: (choices: {
devices: SettingsState['devices']
username: SettingsState['username']
}) => void
}) => {
const { t } = useTranslation('rooms', { keyPrefix: 'join' })
const settingsSnap = useSnapshot(settingsStore)
const [initialUserChoices] = useState({ ...settingsSnap.devices })
const tracks = usePreviewTracks({
audio: settingsSnap.devices.enableMic
? { deviceId: initialUserChoices.micDeviceId }
: false,
video: settingsSnap.devices.enableCamera
? { deviceId: initialUserChoices.cameraDeviceId }
: false,
})
const videoEl = useRef(null)
const videoTrack = useMemo(
() =>
tracks?.filter(
(track) => track.kind === Track.Kind.Video
)[0] as LocalVideoTrack,
[tracks]
)
const audioTrack = useMemo(
() =>
tracks?.filter(
(track) => track.kind === Track.Kind.Audio
)[0] as LocalAudioTrack,
[tracks]
)
useEffect(() => {
if (videoEl.current && videoTrack) {
videoTrack.unmute()
videoTrack.attach(videoEl.current)
}
return () => {
videoTrack?.detach()
}
}, [videoTrack])
const room = useMaybeRoomContext()
const {
devices: micDevices,
activeDeviceId: activeMicDeviceId,
setActiveMediaDevice: setActiveMicDevice,
} = usePersistedMediaDeviceSelect({
kind: 'audioinput',
room,
track: audioTrack,
requestPermissions: true,
})
const {
devices: cameraDevices,
activeDeviceId: activeCameraDeviceId,
setActiveMediaDevice: setActiveCameraDevice,
} = usePersistedMediaDeviceSelect({
kind: 'videoinput',
room,
track: videoTrack,
requestPermissions: true,
})
const speakerDevices = useAudioOutputs()
useEffect(() => {
if (settingsStore.devices.micDeviceId) {
setActiveMicDevice(settingsStore.devices.micDeviceId)
}
if (settingsStore.devices.cameraDeviceId) {
setActiveCameraDevice(settingsStore.devices.cameraDeviceId)
}
}, [setActiveCameraDevice, setActiveMicDevice])
return (
<Screen>
<VerticallyOffCenter>
<Div
className={css({
margin: 'auto',
flexWrap: 'wrap',
width: 'fit-content',
maxWidth: 'full',
display: 'flex',
gap: 2,
paddingX: 1,
flexDirection: 'column',
alignItems: 'center',
lg: {
alignItems: 'stretch',
flexDirection: 'row',
},
})}
>
<VStack
className={css({
width: 'full',
maxWidth: '38rem',
margin: '0 auto',
alignItems: 'center',
flexShrink: '1',
})}
>
<Center
className={css({
width: '38rem',
maxWidth: 'full',
height: 'auto',
aspectRatio: '16 / 9',
background: 'gray.900',
color: 'white',
borderRadius: 16,
overflow: 'hidden',
})}
>
{videoTrack && settingsSnap.devices.enableCamera ? (
// eslint-disable-next-line jsx-a11y/media-has-caption
<video
ref={videoEl}
width="608"
height="342"
className={css({
width: 'full',
height: 'auto',
})}
/>
) : (
settingsSnap.devices.enableCamera === false && (
<p>{t('cameraPlaceholder')}</p>
)
)}
</Center>
<HStack gap={1} justify="center" flexWrap={'wrap'}>
{/* audio output dropdown */}
<Menu>
<Button
tooltip={t('chooseSpeaker')}
aria-label={t('chooseSpeaker')}
>
<RiVolumeUpLine />
<RiArrowDropDownLine />
</Button>
<MenuList
items={speakerDevices.map((d) => ({
value: d.deviceId,
label: d.label,
}))}
selectedItem={settingsSnap.devices.speakerDeviceId}
onAction={(value) => {
settingsStore.devices.speakerDeviceId = value as string
}}
/>
</Menu>
{/* audio input toggle + dropdown */}
<HStack gap={0}>
<ToggleButton
isSelected={settingsSnap.devices.enableMic}
variant={
settingsSnap.devices.enableMic ? undefined : 'danger'
}
toggledStyles={false}
onChange={(enabled) =>
(settingsStore.devices.enableMic = enabled)
}
aria-label={
settingsSnap.devices.enableMic
? `${t('micIsOn')} ${t('toggleOff')}`
: `${t('micIsOff')} ${t('toggleOn')}`
}
tooltip={
settingsSnap.devices.enableMic ? (
<>
{t('micIsOn')}
<br />
{t('toggleOff')}
</>
) : (
<>
{t('micIsOff')}
<br />
{t('toggleOn')}
</>
)
}
groupPosition="left"
>
{settingsSnap.devices.enableMic ? (
<RiMicLine />
) : (
<RiMicOffLine />
)}
</ToggleButton>
<Menu>
<Button
tooltip={t('chooseMic')}
aria-label={t('chooseMic')}
groupPosition="right"
square
>
<RiArrowDropDownLine />
</Button>
<MenuList
items={micDevices.map((d) => ({
value: d.deviceId,
label: d.label,
}))}
selectedItem={activeMicDeviceId}
onAction={(value) => {
setActiveMicDevice(value as string)
}}
/>
</Menu>
</HStack>
{/* video toggle + dropdown */}
<HStack gap={0}>
<ToggleButton
isSelected={settingsSnap.devices.enableCamera}
variant={
settingsSnap.devices.enableCamera ? undefined : 'danger'
}
toggledStyles={false}
onChange={(enabled) =>
(settingsStore.devices.enableCamera = enabled)
}
aria-label={
settingsSnap.devices.enableMic
? `${t('cameraIsOn')} ${t('toggleOff')}`
: `${t('cameraIsOff')} ${t('toggleOn')}`
}
tooltip={
settingsSnap.devices.enableMic ? (
<>
{t('cameraIsOn')}
<br />
{t('toggleOff')}
</>
) : (
<>
{t('cameraIsOff')}
<br />
{t('toggleOn')}
</>
)
}
groupPosition="left"
>
{settingsSnap.devices.enableCamera ? (
<RiVideoOnLine />
) : (
<RiVideoOffLine />
)}
</ToggleButton>
<Menu>
<Button
tooltip={t('chooseCamera')}
aria-label={t('chooseCamera')}
groupPosition="right"
square
>
<RiArrowDropDownLine />
</Button>
<MenuList
items={cameraDevices.map((d) => ({
value: d.deviceId,
label: d.label,
}))}
selectedItem={activeCameraDeviceId}
onAction={(value) => {
setActiveCameraDevice(value as string)
}}
/>
</Menu>
</HStack>
</HStack>
</VStack>
<Div width="24rem" maxWidth="full" flexShrink="1">
<VerticallyOffCenter>
<Center>
<H lvl={1}>{t('heading')}</H>
</Center>
<Form
onSubmit={(data) => {
settingsStore.username = (data.username as string).trim()
onSubmit({
devices: { ...settingsStore.devices },
username: settingsStore.username,
})
}}
submitLabel={t('joinMeeting')}
withSubmitButton={false}
>
<Field
type="text"
name="username"
defaultValue={settingsSnap.username}
label={t('usernameLabel')}
description={t('usernameHint')}
isRequired
/>
<Center>
<Button type="submit" variant="primary">
{t('joinMeeting')}
</Button>
</Center>
</Form>
</VerticallyOffCenter>
</Div>
</Div>
</VerticallyOffCenter>
</Screen>
)
}
@@ -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>
)
}
+23 -15
View File
@@ -1,29 +1,37 @@
import { useState } from 'react'
import { useEffect, useState } from 'react'
import {
usePersistentUserChoices,
type LocalUserChoices,
} from '@livekit/components-react'
import { useParams } from 'wouter'
import { ErrorScreen } from '@/components/ErrorScreen'
import { useUser, UserAware } from '@/features/auth'
import { Conference } from '../components/Conference'
import { HomemadeJoin } from '../components/HomemadeJoin'
import { settingsStore, type SettingsState } from '@/features/settings'
import { useSnapshot } from 'valtio'
import { Join } from '../components/Join'
export const Room = () => {
const { isLoggedIn } = useUser()
const settingsSnap = useSnapshot(settingsStore)
const existingUserConfig = {
username: settingsSnap.username,
devices: settingsSnap.devices,
}
const [userConfig, setUserConfig] = useState<null | {
username: SettingsState['username']
devices: SettingsState['devices']
}>(null)
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'
const clearRouterState = () => {
if (window?.history?.state) {
window.history.replaceState({}, '')
}
}
useEffect(() => {
window.addEventListener('beforeunload', clearRouterState)
return () => {
window.removeEventListener('beforeunload', clearRouterState)
}
}, [])
if (!roomId) {
return <ErrorScreen />
}
@@ -31,7 +39,7 @@ export const Room = () => {
if (!userConfig && !skipJoinScreen) {
return (
<UserAware>
<HomemadeJoin onSubmit={setUserConfig} />
<Join onSubmit={setUserConfig} />
</UserAware>
)
}
@@ -43,7 +51,7 @@ export const Room = () => {
roomId={roomId}
mode={mode}
userConfig={{
...existingUserConfig,
...existingUserChoices,
...userConfig,
}}
/>
@@ -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"
@@ -1,4 +1,2 @@
export { SettingsButton } from './components/SettingsButton'
export { SettingsDialog } from './components/SettingsDialog'
export { settingsStore } from './stores/settings'
export { type SettingsState } from './stores/settings'
@@ -1,50 +0,0 @@
import { proxy, subscribe } from 'valtio'
import { devtools } from 'valtio/utils'
export type SettingsState = {
username: string | undefined
devices: {
/**
* MediaDeviceInfo id
*/
speakerDeviceId: string | undefined
/**
* MediaDeviceInfo id
*/
micDeviceId: string | undefined
/**
* MediaDeviceInfo id
*/
cameraDeviceId: string | undefined
enableMic: boolean
enableCamera: boolean
}
}
// sync the valtio store with localstorage data
// @TODO: make it easier to have "persisted" stores as we will definitely use it quite often
const localData = localStorage.getItem('meet.settings')
export const settingsStore = proxy<SettingsState>(
localData
? JSON.parse(localData)
: {
username: undefined,
devices: {
speakerDeviceId: undefined,
micDeviceId: undefined,
cameraDeviceId: undefined,
enableMic: false,
enableCamera: false,
},
}
)
subscribe(settingsStore, () => {
localStorage.setItem('meet.settings', JSON.stringify(settingsStore))
})
if (import.meta.env.DEV) {
devtools(settingsStore, { name: 'settings', enabled: true })
}
@@ -0,0 +1,28 @@
import { useTranslation } from 'react-i18next'
import { Button, Popover, PopoverList } from '@/primitives'
import { useLanguageLabels } from './useLanguageLabels'
export const LanguageSelector = () => {
const { t, i18n } = useTranslation()
const { languagesList, currentLanguage } = useLanguageLabels()
return (
<Popover aria-label={t('languageSelector.popoverLabel')}>
<Button
aria-label={t('languageSelector.buttonLabel', {
currentLanguage: currentLanguage.label,
})}
size="sm"
variant="primary"
outline
>
{i18n.language}
</Button>
<PopoverList
items={languagesList}
onAction={(lang) => {
i18n.changeLanguage(lang)
}}
/>
</Popover>
)
}
+1 -1
View File
@@ -1,6 +1,6 @@
import type { ReactNode } from 'react'
import { Div, VerticallyOffCenter } from '@/primitives'
import type { SystemStyleObject } from '@/styled-system/types'
import type { SystemStyleObject } from '../styled-system/types'
export const Centered = ({
width = '38rem',
+7 -7
View File
@@ -2,13 +2,11 @@ import { Link } from 'wouter'
import { css } from '@/styled-system/css'
import { Stack } from '@/styled-system/jsx'
import { useTranslation } from 'react-i18next'
import { A, Text, Button } from '@/primitives'
import { A, Button, Popover, PopoverList, Text } from '@/primitives'
import { SettingsButton } from '@/features/settings'
import { authUrl, logoutUrl, useUser } from '@/features/auth'
import { useMatchesRoute } from '@/navigation/useMatchesRoute'
import { Feedback } from '@/components/Feedback'
import { Menu } from '@/primitives/Menu'
import { MenuList } from '@/primitives/MenuList'
export const Header = () => {
const { t } = useTranslation()
@@ -66,7 +64,7 @@ export const Header = () => {
<A href={authUrl()}>{t('login')}</A>
)}
{!!user && (
<Menu>
<Popover aria-label={t('logout')}>
<Button
size="sm"
invisible
@@ -75,15 +73,17 @@ export const Header = () => {
>
{user.email}
</Button>
<MenuList
items={[{ value: 'logout', label: t('logout') }]}
<PopoverList
items={[
{ key: 'logout', value: 'logout', label: t('logout') },
]}
onAction={(value) => {
if (value === 'logout') {
window.location.href = logoutUrl()
}
}}
/>
</Menu>
</Popover>
)}
<SettingsButton />
</Stack>
+7
View File
@@ -7,6 +7,13 @@
"heading": ""
},
"feedbackAlert": "",
"forbidden": {
"heading": ""
},
"languageSelector": {
"buttonLabel": "",
"popoverLabel": ""
},
"loading": "",
"loggedInUserTooltip": "",
"login": "Anmelden",
+37 -14
View File
@@ -4,24 +4,11 @@
"heading": ""
},
"join": {
"cameraIsOff": "",
"cameraIsOn": "",
"cameraPlaceholder": "",
"camlabel": "",
"chooseCamera": "",
"chooseMic": "",
"chooseSpeaker": "",
"heading": "",
"joinLabel": "",
"joinMeeting": "",
"micIsOff": "",
"micIsOn": "",
"micLabel": "",
"toggleOff": "",
"toggleOn": "",
"userLabel": "",
"usernameHint": "",
"usernameLabel": ""
"userLabel": ""
},
"leaveRoomPrompt": "",
"shareDialog": {
@@ -35,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": ""
},
+7
View File
@@ -7,6 +7,13 @@
"heading": "An error occured while loading the page"
},
"feedbackAlert": "Give us feedback",
"forbidden": {
"heading": "You don't have the permission to view this page"
},
"languageSelector": {
"buttonLabel": "Change language (currently {{currentLanguage}})",
"popoverLabel": "Choose language"
},
"loading": "Loading…",
"loggedInUserTooltip": "Logged in as…",
"login": "Login",
+41 -18
View File
@@ -4,24 +4,11 @@
"heading": "Help us improve Meet"
},
"join": {
"cameraIsOff": "Camera is off.",
"cameraIsOn": "Camera is on.",
"cameraPlaceholder": "Turn on the camera to see the preview",
"camlabel": "",
"chooseCamera": "Select camera",
"chooseMic": "Select microphone",
"chooseSpeaker": "Select speakers",
"heading": "Verify your settings",
"joinLabel": "",
"joinMeeting": "Join meeting",
"micIsOff": "Microphone is off.",
"micIsOn": "Microphone is on.",
"micLabel": "",
"toggleOff": "Click to turn off",
"toggleOn": "Click to turn on",
"userLabel": "",
"usernameHint": "Shown to other participants",
"usernameLabel": "Your name"
"camlabel": "Camera",
"heading": "Join the meeting",
"joinLabel": "Join",
"micLabel": "Microphone",
"userLabel": "Your name"
},
"leaveRoomPrompt": "This will make you leave the meeting.",
"shareDialog": {
@@ -35,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"
},
+7
View File
@@ -7,6 +7,13 @@
"heading": "Une erreur est survenue lors du chargement de la page"
},
"feedbackAlert": "Donnez-nous votre avis",
"forbidden": {
"heading": "Accès interdit"
},
"languageSelector": {
"buttonLabel": "Changer de langue (actuellement {{currentLanguage}})",
"popoverLabel": "Choix de la langue"
},
"loading": "Chargement…",
"loggedInUserTooltip": "Connecté en tant que…",
"login": "Se connecter",
+41 -18
View File
@@ -4,24 +4,11 @@
"heading": "Aidez-nous à améliorer Meet"
},
"join": {
"cameraIsOff": "Webcam coupée.",
"cameraIsOn": "Webcam activée.",
"cameraPlaceholder": "Activez la webcam pour prévisualiser l'affichage",
"camlabel": "",
"chooseCamera": "Choisir la webcam",
"chooseMic": "Choisir le micro",
"chooseSpeaker": "Choisir la sortie audio",
"heading": "Vérifiez vos paramètres",
"joinLabel": "",
"joinMeeting": "Rejoindre la réjoindre",
"micIsOff": "Micro coupé.",
"micIsOn": "Micro activé.",
"micLabel": "",
"toggleOff": "Cliquez pour désactiver",
"toggleOn": "Cliquez pour activer",
"userLabel": "",
"usernameHint": "Affiché aux autres participants",
"usernameLabel": "Votre nom"
"camlabel": "Webcam",
"heading": "Rejoindre la réunion",
"joinLabel": "Rejoindre",
"micLabel": "Micro",
"userLabel": "Votre nom"
},
"leaveRoomPrompt": "Revenir à l'accueil vous fera quitter la réunion.",
"shareDialog": {
@@ -35,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"
},
+1 -1
View File
@@ -1,5 +1,5 @@
import { cva } from '@/styled-system/css'
import { styled } from '@/styled-system/jsx'
import { styled } from '../styled-system/jsx'
const box = cva({
base: {
+171 -11
View File
@@ -1,20 +1,151 @@
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 { type RecipeVariantProps } from '@/styled-system/css'
import { buttonRecipe, type ButtonRecipe } from './buttonRecipe'
import { TooltipWrapper, type TooltipWrapperProps } from './TooltipWrapper'
import { cva, type RecipeVariantProps } from '@/styled-system/css'
import { Tooltip } from './Tooltip'
export type ButtonProps = RecipeVariantProps<ButtonRecipe> &
const button = cva({
base: {
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
transition: 'background 200ms, outline 200ms, border-color 200ms',
cursor: 'pointer',
border: '1px solid transparent',
color: 'colorPalette.text',
backgroundColor: 'colorPalette',
'&[data-selected]': {
background: 'colorPalette.active',
},
'&[data-hovered]': {
backgroundColor: 'colorPalette.hover',
},
'&[data-pressed]': {
backgroundColor: 'colorPalette.active',
},
},
variants: {
size: {
default: {
borderRadius: 8,
paddingX: '1',
paddingY: '0.625',
'--square-padding': '{spacing.0.625}',
},
sm: {
borderRadius: 4,
paddingX: '0.5',
paddingY: '0.25',
'--square-padding': '{spacing.0.25}',
},
xs: {
borderRadius: 4,
'--square-padding': '0',
},
},
square: {
true: {
paddingX: 'var(--square-padding)',
paddingY: 'var(--square-padding)',
},
},
variant: {
default: {
colorPalette: 'control',
},
primary: {
colorPalette: 'primary',
},
// @TODO: better handling of colors… this is a mess
success: {
colorPalette: 'success',
borderColor: 'success.300',
color: 'success.subtle-text',
backgroundColor: 'success.subtle',
'&[data-hovered]': {
backgroundColor: 'success.200',
},
'&[data-pressed]': {
backgroundColor: 'success.subtle!',
},
},
},
outline: {
true: {
color: 'colorPalette',
backgroundColor: 'transparent!',
borderColor: 'currentcolor!',
'&[data-hovered]': {
backgroundColor: 'colorPalette.subtle!',
},
'&[data-pressed]': {
backgroundColor: 'colorPalette.subtle!',
},
},
},
invisible: {
true: {
borderColor: 'none!',
backgroundColor: 'none!',
'&[data-hovered]': {
backgroundColor: 'none!',
borderColor: 'colorPalette.active!',
},
'&[data-pressed]': {
borderColor: 'currentcolor',
},
},
},
fullWidth: {
true: {
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',
variant: 'default',
outline: false,
},
})
type Tooltip = {
tooltip?: string
tooltipType?: 'instant' | 'delayed'
}
export type ButtonProps = RecipeVariantProps<typeof button> &
RACButtonsProps &
TooltipWrapperProps
Tooltip & {
toggle?: boolean
isSelected?: boolean
}
type LinkButtonProps = RecipeVariantProps<ButtonRecipe> &
LinkProps &
TooltipWrapperProps
type LinkButtonProps = RecipeVariantProps<typeof button> & LinkProps & Tooltip
type ButtonOrLinkProps = ButtonProps | LinkButtonProps
@@ -23,20 +154,49 @@ export const Button = ({
tooltipType = 'instant',
...props
}: ButtonOrLinkProps) => {
const [variantProps, componentProps] = buttonRecipe.splitVariantProps(props)
const [variantProps, componentProps] = button.splitVariantProps(props)
if ((props as LinkButtonProps).href !== undefined) {
return (
<TooltipWrapper tooltip={tooltip} tooltipType={tooltipType}>
<Link className={buttonRecipe(variantProps)} {...componentProps} />
<Link className={button(variantProps)} {...componentProps} />
</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
className={buttonRecipe(variantProps)}
className={button(variantProps)}
{...(componentProps as RACButtonsProps)}
/>
</TooltipWrapper>
)
}
const TooltipWrapper = ({
tooltip,
tooltipType,
children,
}: {
children: ReactNode
} & Tooltip) => {
return tooltip ? (
<TooltipTrigger delay={tooltipType === 'instant' ? 300 : 1000}>
{children}
<Tooltip>{tooltip}</Tooltip>
</TooltipTrigger>
) : (
children
)
}
+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,12 +0,0 @@
import { ReactNode, useContext } from 'react'
import { OverlayTriggerStateContext } from 'react-aria-components'
/**
* Small helper you can use as a wrapper of a <Dialog> component if you want to make sure it is not rendered when it is closed.
*
* Not required all the time, it's mostly helpful to avoid calling hooks of a child component that uses a Dialog.
*/
export const DialogContainer = ({ children }: { children: ReactNode }) => {
const state = useContext(OverlayTriggerStateContext)!
return state.isOpen ? children : null
}
+9 -13
View File
@@ -13,7 +13,6 @@ import { Button, useCloseDialog } from '@/primitives'
export const Form = ({
onSubmit,
submitLabel,
withSubmitButton = true,
withCancelButton = true,
onCancelButtonPress,
children,
@@ -26,7 +25,6 @@ export const Form = ({
event: FormEvent<HTMLFormElement>
) => void
submitLabel: string
withSubmitButton?: boolean
withCancelButton?: boolean
onCancelButtonPress?: () => void
}) => {
@@ -48,18 +46,16 @@ export const Form = ({
}}
>
{children}
{withSubmitButton && (
<HStack gap="gutter">
<Button type="submit" variant="primary">
{submitLabel}
<HStack gap="gutter">
<Button type="submit" variant="primary">
{submitLabel}
</Button>
{!!onCancel && (
<Button variant="primary" outline onPress={() => onCancel()}>
{t('cancel')}
</Button>
{!!onCancel && (
<Button variant="primary" outline onPress={() => onCancel()}>
{t('cancel')}
</Button>
)}
</HStack>
)}
)}
</HStack>
</RACForm>
)
}
-25
View File
@@ -1,25 +0,0 @@
import { ReactNode } from 'react'
import { MenuTrigger } from 'react-aria-components'
import { StyledPopover } from './Popover'
import { Box } from './Box'
/**
* a Menu is a tuple of a trigger component (most usually a Button) that toggles menu items in a tooltip around the trigger
*/
export const Menu = ({
children,
}: {
children: [trigger: ReactNode, menu: ReactNode]
}) => {
const [trigger, menu] = children
return (
<MenuTrigger>
{trigger}
<StyledPopover>
<Box size="sm" type="popover">
{menu}
</Box>
</StyledPopover>
</MenuTrigger>
)
}
-42
View File
@@ -1,42 +0,0 @@
import { ReactNode } from 'react'
import { Menu, MenuProps, MenuItem } from 'react-aria-components'
import { menuItemRecipe } from './menuItemRecipe'
/**
* render a Button primitive that shows a popover showing a list of pressable items
*/
export const MenuList = <T extends string | number = string>({
onAction,
selectedItem,
items = [],
...menuProps
}: {
onAction: (key: T) => void
selectedItem?: T
items: Array<string | { value: T; label: ReactNode }>
} & MenuProps<unknown>) => {
return (
<Menu
selectionMode={selectedItem !== undefined ? 'single' : undefined}
selectedKeys={selectedItem !== undefined ? [selectedItem] : undefined}
{...menuProps}
>
{items.map((item) => {
const value = typeof item === 'string' ? item : item.value
const label = typeof item === 'string' ? item : item.label
return (
<MenuItem
className={menuItemRecipe()}
key={value}
id={value as string}
onAction={() => {
onAction(value as T)
}}
>
{label}
</MenuItem>
)
})}
</Menu>
)
}
+1 -4
View File
@@ -49,10 +49,7 @@ const StyledOverlayArrow = styled(OverlayArrow, {
})
/**
* a Popover is a tuple of a trigger component (most usually a Button) that toggles some content in a tooltip around the trigger
*
* Note: to show a list of actionable items, like a dropdown menu, prefer using a <Menu> or <Select>.
* This is here when needing to show unrestricted content in a box.
* a Popover is a tuple of a trigger component (most usually a Button) that toggles some interactive content in a tooltip around the trigger
*/
export const Popover = ({
children,
@@ -0,0 +1,72 @@
import { ReactNode, useContext } from 'react'
import {
ButtonProps,
Button,
OverlayTriggerStateContext,
} from 'react-aria-components'
import { styled } from '@/styled-system/jsx'
const ListItem = styled(Button, {
base: {
paddingY: 0.125,
paddingX: 0.5,
textAlign: 'left',
width: 'full',
borderRadius: 4,
cursor: 'pointer',
color: 'box.text',
border: '1px solid transparent',
'&[data-selected]': {
fontWeight: 'bold',
},
'&[data-focused]': {
color: 'primary.text',
backgroundColor: 'primary',
outline: 'none!',
},
'&[data-hovered]': {
color: 'primary.text',
backgroundColor: 'primary',
outline: 'none!',
},
},
})
/**
* render a Button primitive that shows a popover showing a list of pressable items
*/
export const PopoverList = <T extends string | number = string>({
onAction,
closeOnAction = true,
items = [],
}: {
closeOnAction?: boolean
onAction: (key: T) => void
items: Array<string | { key: string; value: T; label: ReactNode }>
} & ButtonProps) => {
const popoverState = useContext(OverlayTriggerStateContext)!
return (
<ul>
{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 key={key}>
<ListItem
key={value}
onPress={() => {
onAction(value as T)
if (closeOnAction) {
popoverState.close()
}
}}
>
{label}
</ListItem>
</li>
)
})}
</ul>
)
}
+23 -7
View File
@@ -11,7 +11,6 @@ import {
} from 'react-aria-components'
import { Box } from './Box'
import { StyledPopover } from './Popover'
import { menuItemRecipe } from './menuItemRecipe'
const StyledButton = styled(Button, {
base: {
@@ -44,6 +43,27 @@ const StyledSelectValue = styled(SelectValue, {
},
})
const StyledListBoxItem = styled(ListBoxItem, {
base: {
paddingY: 0.125,
paddingX: 0.5,
textAlign: 'left',
width: 'full',
borderRadius: 4,
cursor: 'pointer',
color: 'box.text',
border: '1px solid transparent',
'&[data-selected]': {
fontWeight: 'bold',
},
'&[data-focused]': {
color: 'primary.text',
backgroundColor: 'primary',
outline: 'none!',
},
},
})
export const Select = <T extends string | number>({
label,
items,
@@ -65,13 +85,9 @@ export const Select = <T extends string | number>({
<Box size="sm" type="popover" variant="control">
<ListBox>
{items.map((item) => (
<ListBoxItem
className={menuItemRecipe()}
id={item.value}
key={item.value}
>
<StyledListBoxItem id={item.value} key={item.value}>
{item.label}
</ListBoxItem>
</StyledListBoxItem>
))}
</ListBox>
</Box>
@@ -1,25 +0,0 @@
import {
ToggleButton as RACToggleButton,
ToggleButtonProps,
} from 'react-aria-components'
import { type ButtonRecipeProps, buttonRecipe } from './buttonRecipe'
import { TooltipWrapper, TooltipWrapperProps } from './TooltipWrapper'
/**
* React aria ToggleButton with our button styles, that can take a tooltip if needed
*/
export const ToggleButton = ({
tooltip,
tooltipType,
...props
}: ToggleButtonProps & ButtonRecipeProps & TooltipWrapperProps) => {
const [variantProps, componentProps] = buttonRecipe.splitVariantProps(props)
return (
<TooltipWrapper tooltip={tooltip} tooltipType={tooltipType}>
<RACToggleButton
{...componentProps}
className={buttonRecipe(variantProps)}
/>
</TooltipWrapper>
)
}
@@ -2,41 +2,16 @@ import { type ReactNode } from 'react'
import {
OverlayArrow,
Tooltip as RACTooltip,
TooltipTrigger,
type TooltipProps,
TooltipProps,
} from 'react-aria-components'
import { styled } from '@/styled-system/jsx'
export type TooltipWrapperProps = {
tooltip?: ReactNode
tooltipType?: 'instant' | 'delayed'
}
/**
* Wrap a component you want to apply a tooltip on (for example a Button)
*
* If no tooltip is given, just returns children
*/
export const TooltipWrapper = ({
tooltip,
tooltipType,
children,
}: {
children: ReactNode
} & TooltipWrapperProps) => {
return tooltip ? (
<TooltipTrigger delay={tooltipType === 'instant' ? 300 : 1000}>
{children}
<Tooltip>{tooltip}</Tooltip>
</TooltipTrigger>
) : (
children
)
}
/**
* Styled react aria Tooltip component.
*
* Note that tooltips are directly handled by Buttons via the `tooltip` prop,
* so you should not need to use this component directly.
*
* Style taken from example at https://react-spectrum.adobe.com/react-aria/Tooltip.html
*/
const StyledTooltip = styled(RACTooltip, {
@@ -105,7 +80,7 @@ const TooltipArrow = () => {
)
}
const Tooltip = ({
export const Tooltip = ({
children,
...props
}: Omit<TooltipProps, 'children'> & { children: ReactNode }) => {
+1 -1
View File
@@ -1,4 +1,4 @@
import { styled } from '@/styled-system/jsx'
import { styled } from '../styled-system/jsx'
export const Ul = styled('ul', {
base: {
-149
View File
@@ -1,149 +0,0 @@
import { type RecipeVariantProps, cva } from '@/styled-system/css'
export type ButtonRecipe = typeof buttonRecipe
export type ButtonRecipeProps = RecipeVariantProps<ButtonRecipe>
export const buttonRecipe = cva({
base: {
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
transition: 'background 200ms, outline 200ms, border-color 200ms',
cursor: 'pointer',
border: '1px solid transparent',
color: 'colorPalette.text',
backgroundColor: 'colorPalette',
'&[data-hovered]': {
backgroundColor: 'colorPalette.hover',
},
'&[data-pressed]': {
backgroundColor: 'colorPalette.active',
},
'&[data-selected]': {
backgroundColor: 'colorPalette.active',
},
},
variants: {
size: {
default: {
borderRadius: 8,
paddingX: '1',
paddingY: '0.625',
'--square-padding': '{spacing.0.625}',
},
sm: {
borderRadius: 4,
paddingX: '0.5',
paddingY: '0.25',
'--square-padding': '{spacing.0.25}',
},
xs: {
borderRadius: 4,
'--square-padding': '0',
},
},
square: {
true: {
paddingX: 'var(--square-padding)',
paddingY: 'var(--square-padding)',
},
},
variant: {
default: {
colorPalette: 'control',
borderColor: 'control.subtle',
},
primary: {
colorPalette: 'primary',
},
// @TODO: better handling of colors… this is a mess
success: {
colorPalette: 'success',
borderColor: 'success.300',
color: 'success.subtle-text',
backgroundColor: 'success.subtle',
'&[data-hovered]': {
backgroundColor: 'success.200',
},
'&[data-pressed]': {
backgroundColor: 'success.subtle!',
},
},
danger: {
colorPalette: 'danger',
borderColor: 'danger.600',
color: 'danger.subtle-text',
backgroundColor: 'danger.subtle',
'&[data-hovered]': {
backgroundColor: 'danger.200',
},
'&[data-pressed]': {
backgroundColor: 'danger.subtle!',
},
},
},
outline: {
true: {
color: 'colorPalette',
backgroundColor: 'transparent!',
borderColor: 'currentcolor!',
'&[data-hovered]': {
backgroundColor: 'colorPalette.subtle!',
},
'&[data-pressed]': {
backgroundColor: 'colorPalette.subtle!',
},
},
},
invisible: {
true: {
borderColor: 'none!',
backgroundColor: 'none!',
'&[data-hovered]': {
backgroundColor: 'none!',
borderColor: 'colorPalette.active!',
},
'&[data-pressed]': {
borderColor: 'currentcolor',
},
},
},
fullWidth: {
true: {
width: 'full',
},
},
/**
* if the button is next to other ones to make a "button group", tell where the button is to handle radius
*/
groupPosition: {
left: {
borderTopRightRadius: 0,
borderBottomRightRadius: 0,
},
right: {
borderTopLeftRadius: 0,
borderBottomLeftRadius: 0,
borderLeft: 0,
},
center: {
borderRadius: 0,
},
},
// some toggle buttons make more sense without a "pushed button" style when enabled because their content changes to mark the state
toggledStyles: {
false: {
'&[data-selected]': {
backgroundColor: 'colorPalette',
},
},
},
},
defaultVariants: {
size: 'default',
variant: 'default',
outline: false,
toggledStyles: true,
},
})
+1 -10
View File
@@ -1,9 +1,3 @@
/**
* exposes all primitives we want to use in other parts of the app.
*
* It's intended not everything is exported: some primitives are meant as building-blocks
* for other primitives and don't have any value being exposed.
*/
export { A } from './A'
export { Badge } from './Badge'
export { Bold } from './Bold'
@@ -11,7 +5,6 @@ export { Box } from './Box'
export { Button } from './Button'
export { useCloseDialog } from './useCloseDialog'
export { Dialog, type DialogProps } from './Dialog'
export { DialogContainer } from './DialogContainer'
export { Div } from './Div'
export { Field } from './Field'
export { Form } from './Form'
@@ -20,11 +13,9 @@ export { Hr } from './Hr'
export { Italic } from './Italic'
export { Input } from './Input'
export { Link } from './Link'
export { Menu } from './Menu'
export { MenuList } from './MenuList'
export { P } from './P'
export { Popover } from './Popover'
export { PopoverList } from './PopoverList'
export { Text } from './Text'
export { ToggleButton } from './ToggleButton'
export { Ul } from './Ul'
export { VerticallyOffCenter } from './VerticallyOffCenter'
@@ -1,40 +0,0 @@
import { cva } from '@/styled-system/css'
/**
* reusable styles for a menu item, select item, etc… to be used with panda `css()` or `styled()`
*
* these are in their own files because react hot refresh doesn't like exporting stuff
* that aren't components in component files
*/
export const menuItemRecipe = cva({
base: {
paddingY: 0.125,
paddingX: 0.5,
paddingLeft: 1.5,
textAlign: 'left',
width: 'full',
borderRadius: 4,
cursor: 'pointer',
color: 'box.text',
border: '1px solid transparent',
position: 'relative',
'&[data-selected]': {
'&::before': {
content: '"✓"',
position: 'absolute',
top: '2px',
left: '6px',
},
},
'&[data-focused]': {
color: 'primary.text',
backgroundColor: 'primary',
outline: 'none!',
},
'&[data-hovered]': {
color: 'primary.text',
backgroundColor: 'primary',
outline: 'none!',
},
},
})
+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)
}
+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: {}
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "mail_mjml",
"version": "0.1.3",
"version": "0.1.4",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "mail_mjml",
"version": "0.1.3",
"version": "0.1.4",
"license": "MIT",
"dependencies": {
"@html-to/text-cli": "0.5.4",
+1 -1
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": {