Compare commits
30 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 223cee3828 | |||
| 101be503bf | |||
| 47e2d85471 | |||
| 16321b3cb0 | |||
| 144cb56cda | |||
| e6a15e36b4 | |||
| 3541af5992 | |||
| 68f0ea3639 | |||
| 925eb92c60 | |||
| d965ee7060 | |||
| aef85bb1ab | |||
| 8f59b08088 | |||
| 366998b3d6 | |||
| c8ad0ab24f | |||
| d2bbcb0f51 | |||
| 40457bd68a | |||
| c21c5f604b | |||
| 66350b8fb5 | |||
| 378cc3a651 | |||
| 28f43fb2c0 | |||
| 37eea16a50 | |||
| fe8ed43aae | |||
| 671aa68804 | |||
| 6449aaf7ea | |||
| f8fff3dbdf | |||
| 1715ec10dd | |||
| 8c7aed4b00 | |||
| 217b19e42a | |||
| bd26a0abc1 | |||
| abb708aa49 |
@@ -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}"
|
||||
}
|
||||
|
||||
@@ -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 "$@"
|
||||
@@ -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 "$@"
|
||||
@@ -1,3 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
find . -name "*.enc.*" -exec sops updatekeys -y {} \;
|
||||
@@ -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'),
|
||||
),
|
||||
]
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
Generated
+2
-2
@@ -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,7 +1,7 @@
|
||||
{
|
||||
"name": "meet",
|
||||
"private": true,
|
||||
"version": "0.1.3",
|
||||
"version": "0.1.4",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "panda codegen && vite",
|
||||
|
||||
@@ -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}' },
|
||||
|
||||
@@ -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)}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
+160
@@ -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>
|
||||
)
|
||||
}
|
||||
+75
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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,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',
|
||||
|
||||
@@ -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,6 +7,13 @@
|
||||
"heading": ""
|
||||
},
|
||||
"feedbackAlert": "",
|
||||
"forbidden": {
|
||||
"heading": ""
|
||||
},
|
||||
"languageSelector": {
|
||||
"buttonLabel": "",
|
||||
"popoverLabel": ""
|
||||
},
|
||||
"loading": "",
|
||||
"loggedInUserTooltip": "",
|
||||
"login": "Anmelden",
|
||||
|
||||
@@ -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,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",
|
||||
|
||||
@@ -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,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",
|
||||
|
||||
@@ -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,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: {
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
+5
-30
@@ -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,4 +1,4 @@
|
||||
import { styled } from '@/styled-system/jsx'
|
||||
import { styled } from '../styled-system/jsx'
|
||||
|
||||
export const Ul = styled('ul', {
|
||||
base: {
|
||||
|
||||
@@ -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,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!',
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,9 @@
|
||||
import { proxy } from 'valtio'
|
||||
|
||||
type State = {
|
||||
showParticipants: boolean
|
||||
}
|
||||
|
||||
export const participantsStore = proxy<State>({
|
||||
showParticipants: false,
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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>>
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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: {}
|
||||
|
||||
Generated
+2
-2
@@ -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,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": {
|
||||
|
||||
Reference in New Issue
Block a user