Compare commits

..

15 Commits

Author SHA1 Message Date
lebaudantoine 64aa6bbc40 🚸(frontend) replace effects icons in menu option
Align effects icon with the one from the newly refactored pre-join screen.
This new icon is stylish.
2025-01-31 11:37:30 +01:00
lebaudantoine 4ae3d965f9 🚸(frontend) show support toggle only if support is enabled
Avoid misleading the user, if support is not enabled, as it's optional,
avoid displaying a useless control button.

Requested by Dutch counterparts.
2025-01-29 16:12:47 +01:00
lebaudantoine 8eab45b6d5 🩹(frontend) prevent runtime error when Crisp is not initialized
Prevents runtime error when Crisp chat hasn't initialized before component mount
Previously caused crash since we assumed $crisp global was always available.
2025-01-29 16:12:47 +01:00
lebaudantoine 4347d87f33 🚸(frontend) improve prejoin UX
- Always enable camera/mic by default (like Google Meet)
- Fix video state transitions and add visual feedback
- Simplify form using React Aria components
- Reduce shadow intensity for better visual balance
2025-01-29 14:14:11 +01:00
lebaudantoine 1b52d76168 ♻️(frontend) extract menu items into individual components
Each menu item is now a standalone component, improving:
- Code organization & reusability
- Maintainability by reducing OptionsMenuItems complexity

This breaks down large components.
2025-01-27 23:02:31 +01:00
lebaudantoine a44b6e8e34 (frontend) add fullscreen mode on desktop
Added an option allowing users to trigger the fullscreen mode while on desktop.
Heavily inspired by the PR #279 from @sylvinus.

Yet, this option allow user to enable/disable the fullscreen mode on the whole
ui, in the next iteration I'll add the same feature but for a given video track.

This is on purpose that the feature is available on desktop only.
The hook code has been partially written by Claude and inspired by @sylvinus
first suggestion.
2025-01-27 23:02:31 +01:00
lebaudantoine 126de638cd 🩹(frontend) fix allowed hosts by Vite using the Tilt stack
When using nip.io for local development DNS mapping (which allows hostname-based
access to localhost), we need to explicitly allow these domains in Vite's server
configuration to prevent host security violations.

This check should be ignored when using https.
2025-01-27 22:57:51 +01:00
lebaudantoine bf28c5cb84 📱(frontend) enhance control bar responsiveness for better UX
Implemented collapsible advanced options to maintain usability on narrow screens
following GMeet's UX pattern. Dialog and popover components were chosen
based on GMeet choices, though this introduces potential accessibility concerns
that should be addressed in future iterations.

Current implementation uses JS for breakpoint handling due to challenges with
Panda CSS's pure CSS approach. This workaround was necessary to resolve a
persistent issue where the popover remained open after window expansion beyond
750px, even after the lateral menu trigger was removed from view.

Technical debt note: Code needs refinement, particularly around breakpoint
management and component architecture. Prioritized shipping over perfection to
meet immediate responsive design needs.
2025-01-27 22:57:24 +01:00
renovate[bot] 4f0e7d2c52 ⬆️(dependencies) update python dependencies 2025-01-27 22:18:32 +01:00
renovate[bot] 3ab5c48296 ⬆️(dependencies) update js dependencies 2025-01-27 18:12:44 +01:00
Nathan Vasse 7f1f573af8 (front) revamp join screen
We want this screen to have a better ux, the join button was invisible on
some small screen sizes, also we want to align the style of this screen with
the ui of the video conference previously made.
2025-01-27 15:31:19 +01:00
Nathan Vasse d48a18b36b (front) make ToggleDevice work outside of a room
This component is needed on the join screen thus out of a room context.
2025-01-27 15:31:19 +01:00
Nathan Vasse eac9158734 (front) add customization to Field
We want to be able to provide some custom attributes to the FieldWrapper
and its label too to manage width, alignment.
2025-01-27 15:31:19 +01:00
Nathan Vasse 994f335266 (front) add customization to SelectToggleDevice
We want to be able to change the variant of this component.
2025-01-27 15:31:19 +01:00
lebaudantoine 30bde2fe66 ♻️(frontend) use pandas utilities
Forgot how panda css is amazing. They provide all kinds of utilities,
and few for accessibility. Amazing!
2025-01-24 13:56:32 +01:00
25 changed files with 625 additions and 133 deletions
+2 -2
View File
@@ -25,7 +25,7 @@ license = { file = "LICENSE" }
readme = "README.md"
requires-python = ">=3.10"
dependencies = [
"boto3==1.36.2",
"boto3==1.36.6",
"Brotli==1.1.0",
"brevo-python==1.1.2",
"celery[redis]==5.4.0",
@@ -83,7 +83,7 @@ dev = [
"pytest-icdiff==0.9",
"pytest-xdist==3.6.1",
"responses==0.25.6",
"ruff==0.9.2",
"ruff==0.9.3",
"types-requests==2.32.0.20241016",
]
+35 -32
View File
@@ -8,7 +8,7 @@
"name": "meet",
"version": "0.1.12",
"dependencies": {
"@livekit/components-react": "2.8.0",
"@livekit/components-react": "2.8.1",
"@livekit/components-styles": "1.1.4",
"@livekit/track-processors": "0.3.3",
"@pandacss/preset-panda": "0.51.1",
@@ -17,25 +17,25 @@
"@tanstack/react-query": "5.64.2",
"crisp-sdk-web": "1.0.25",
"hoofd": "1.7.3",
"i18next": "24.2.1",
"i18next": "24.2.2",
"i18next-browser-languagedetector": "8.0.2",
"i18next-parser": "9.1.0",
"i18next-resources-to-backend": "1.2.1",
"livekit-client": "2.8.1",
"posthog-js": "1.207.0",
"posthog-js": "1.210.2",
"react": "18.3.1",
"react-aria-components": "1.6.0",
"react-dom": "18.3.1",
"react-i18next": "15.1.1",
"use-sound": "4.0.3",
"valtio": "2.1.2",
"wouter": "3.3.5"
"valtio": "2.1.3",
"wouter": "3.4.0"
},
"devDependencies": {
"@pandacss/dev": "0.51.1",
"@tanstack/eslint-plugin-query": "5.64.2",
"@tanstack/react-query-devtools": "5.64.2",
"@types/node": "22.10.7",
"@types/node": "22.10.10",
"@types/react": "18.3.12",
"@types/react-dom": "18.3.1",
"@typescript-eslint/eslint-plugin": "8.21.0",
@@ -49,7 +49,7 @@
"postcss": "8.5.1",
"prettier": "3.4.2",
"typescript": "5.7.3",
"vite": "6.0.10",
"vite": "6.0.11",
"vite-tsconfig-paths": "5.1.4"
}
},
@@ -436,6 +436,7 @@
},
"node_modules/@clack/prompts/node_modules/is-unicode-supported": {
"version": "1.3.0",
"dev": true,
"inBundle": true,
"license": "MIT",
"engines": {
@@ -1240,9 +1241,9 @@
}
},
"node_modules/@livekit/components-react": {
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/@livekit/components-react/-/components-react-2.8.0.tgz",
"integrity": "sha512-Zgt2zv+j2gWJad0mcTDR8cS6KQNXYfAeOL9Si5Lb2Q2k3iPUJinPDMqubM6btnduhkPjy+EF4kA8bNAY0xu4Ig==",
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/@livekit/components-react/-/components-react-2.8.1.tgz",
"integrity": "sha512-XpuDu7iDMcN4pkV8CYNzHf9hLNdYOeEtbmCr7Zesy6Au3BxUl4aS1Ajmg0b75Rx7zTlkyCJt9Lm4VrEqbJCI6Q==",
"license": "Apache-2.0",
"dependencies": {
"@livekit/components-core": "0.12.1",
@@ -3879,9 +3880,9 @@
"integrity": "sha512-Klz949h02Gz2uZCMGwDUSDS1YBlTdDDgbWHi+81l29tQALUtvz4rAYi5uoVhE5Lagoq6DeqAUlbrHvW/mXDgdQ=="
},
"node_modules/@types/node": {
"version": "22.10.7",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.10.7.tgz",
"integrity": "sha512-V09KvXxFiutGp6B7XkpaDXlNadZxrzajcY50EuoLIpQ6WWYCSvf19lVIazzfIzQvhUN2HjX12spLojTnhuKlGg==",
"version": "22.10.10",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.10.10.tgz",
"integrity": "sha512-X47y/mPNzxviAGY5TcYPtYL8JsY3kAq2n8fMmKoRCxq/c4v4pyGNCzM2R6+M5/umG4ZfHuT+sgqDYqWc9rJ6ww==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -6395,9 +6396,9 @@
}
},
"node_modules/i18next": {
"version": "24.2.1",
"resolved": "https://registry.npmjs.org/i18next/-/i18next-24.2.1.tgz",
"integrity": "sha512-Q2wC1TjWcSikn1VAJg13UGIjc+okpFxQTxjVAymOnSA3RpttBQNMPf2ovcgoFVsV4QNxTfNZMAxorXZXsk4fBA==",
"version": "24.2.2",
"resolved": "https://registry.npmjs.org/i18next/-/i18next-24.2.2.tgz",
"integrity": "sha512-NE6i86lBCKRYZa5TaUDkU5S4HFgLIEJRLr3Whf2psgaxBleQ2LC1YW1Vc+SCgkAW7VEzndT6al6+CzegSUHcTQ==",
"funding": [
{
"type": "individual",
@@ -8444,9 +8445,9 @@
"license": "MIT"
},
"node_modules/posthog-js": {
"version": "1.207.0",
"resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.207.0.tgz",
"integrity": "sha512-Sx+xamhg1/iKGAtUNh3uAUtAAza4j/yBhxcfUxfqR++WrZdw0V6nmh7LSfVNl7+QVl2qmiPSoZA7z+5ojaWDDQ==",
"version": "1.210.2",
"resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.210.2.tgz",
"integrity": "sha512-rIbn/h9ur7uA0PS4dClOr9w6txLfHS94yh9yafA5VM2eXToM951XtMYtIQ6bi6wFzpvpFvTQFeYLQ/9/xZ59AQ==",
"license": "MIT",
"dependencies": {
"core-js": "^3.38.1",
@@ -8503,9 +8504,10 @@
}
},
"node_modules/proxy-compare": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/proxy-compare/-/proxy-compare-3.0.0.tgz",
"integrity": "sha512-y44MCkgtZUCT9tZGuE278fB7PWVf7fRYy0vbRXAts2o5F0EfC4fIQrvQQGBJo1WJbFcVLXzApOscyJuZqHQc1w=="
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/proxy-compare/-/proxy-compare-3.0.1.tgz",
"integrity": "sha512-V9plBAt3qjMlS1+nC8771KNf6oJ12gExvaxnNzN/9yVRLdTv/lc+oJlnSzrdYDAvBfTStPCoiaCOTmTs0adv7Q==",
"license": "MIT"
},
"node_modules/punycode": {
"version": "2.3.1",
@@ -9791,12 +9793,12 @@
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="
},
"node_modules/valtio": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/valtio/-/valtio-2.1.2.tgz",
"integrity": "sha512-fhekN5Rq7dvHULHHBlJeXHrQDl0Jj9GXfNavCm3gkD06crGchaG1nf/J7gSlfZU2wPcRdVS5jBKWHtE2NNz97A==",
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/valtio/-/valtio-2.1.3.tgz",
"integrity": "sha512-q+dVczY5zf7kO39JPVq2qR0VXMeEpF1t+P1njHU7hAhwgtyVa/p38YmgyHZq2IQYwnkNrkvh412k4sfDmcIJ2g==",
"license": "MIT",
"dependencies": {
"proxy-compare": "^3.0.0"
"proxy-compare": "^3.0.1"
},
"engines": {
"node": ">=12.20.0"
@@ -9890,9 +9892,9 @@
}
},
"node_modules/vite": {
"version": "6.0.10",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.0.10.tgz",
"integrity": "sha512-MEszunEcMo6pFsfXN1GhCFQqnE25tWRH0MA4f0Q7uanACi4y1Us+ZGpTMnITwCTnYzB2b9cpmnelTlxgTBmaBA==",
"version": "6.0.11",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.0.11.tgz",
"integrity": "sha512-4VL9mQPKoHy4+FE0NnRE/kbY51TOfaknxAjt3fJbGJxhIpBZiqVzlZDEesWWsuREXHwNdAoOFZ9MkPEVXczHwg==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -10582,9 +10584,10 @@
}
},
"node_modules/wouter": {
"version": "3.3.5",
"resolved": "https://registry.npmjs.org/wouter/-/wouter-3.3.5.tgz",
"integrity": "sha512-bx3fLQAMn+EhYbBdY3W1gw9ZfO/uchudxYMwOIBzF3HVgqNEEIT199vEoh7FLTC0Vz5+rpMO6NdFsOkGX1QQCw==",
"version": "3.4.0",
"resolved": "https://registry.npmjs.org/wouter/-/wouter-3.4.0.tgz",
"integrity": "sha512-Qx4L+EnYk7AF+pNU9TM+lQtkceL5Qm+oDZU9720DUx+/7K6v7fnlHnRLI78KjEbCqdGFeroW5P0M1t8WYSloQA==",
"license": "Unlicense",
"dependencies": {
"mitt": "^3.0.1",
"regexparam": "^3.0.0",
+7 -7
View File
@@ -13,7 +13,7 @@
"check": "prettier --check ./src"
},
"dependencies": {
"@livekit/components-react": "2.8.0",
"@livekit/components-react": "2.8.1",
"@livekit/components-styles": "1.1.4",
"@livekit/track-processors": "0.3.3",
"@pandacss/preset-panda": "0.51.1",
@@ -22,25 +22,25 @@
"@tanstack/react-query": "5.64.2",
"crisp-sdk-web": "1.0.25",
"hoofd": "1.7.3",
"i18next": "24.2.1",
"i18next": "24.2.2",
"i18next-browser-languagedetector": "8.0.2",
"i18next-parser": "9.1.0",
"i18next-resources-to-backend": "1.2.1",
"livekit-client": "2.8.1",
"posthog-js": "1.207.0",
"posthog-js": "1.210.2",
"react": "18.3.1",
"react-aria-components": "1.6.0",
"react-dom": "18.3.1",
"react-i18next": "15.1.1",
"use-sound": "4.0.3",
"valtio": "2.1.2",
"wouter": "3.3.5"
"valtio": "2.1.3",
"wouter": "3.4.0"
},
"devDependencies": {
"@pandacss/dev": "0.51.1",
"@tanstack/eslint-plugin-query": "5.64.2",
"@tanstack/react-query-devtools": "5.64.2",
"@types/node": "22.10.7",
"@types/node": "22.10.10",
"@types/react": "18.3.12",
"@types/react-dom": "18.3.1",
"@typescript-eslint/eslint-plugin": "8.21.0",
@@ -54,7 +54,7 @@
"postcss": "8.5.1",
"prettier": "3.4.2",
"typescript": "5.7.3",
"vite": "6.0.10",
"vite": "6.0.11",
"vite-tsconfig-paths": "5.1.4"
}
}
@@ -32,10 +32,10 @@ const Columns = ({ children }: { children?: ReactNode }) => {
justifyContent: 'normal',
padding: '0 1rem',
width: 'calc(100% - 2rem)',
'@media(prefers-reduced-motion: reduce)': {
_motionReduce: {
opacity: 1,
},
'@media(prefers-reduced-motion: no-preference)': {
_motionSafe: {
opacity: 0,
animation: '.5s ease-in fade 0s forwards',
},
@@ -1,30 +1,281 @@
import { useTranslation } from 'react-i18next'
import { PreJoin, type LocalUserChoices } from '@livekit/components-react'
import {
usePersistentUserChoices,
usePreviewTracks,
type LocalUserChoices,
} from '@livekit/components-react'
import { css } from '@/styled-system/css'
import { Screen } from '@/layout/Screen'
import { CenteredContent } from '@/layout/CenteredContent'
import { useUser } from '@/features/auth'
import { useMemo, useEffect, useRef, useState } from 'react'
import { LocalVideoTrack, Track } from 'livekit-client'
import { H } from '@/primitives/H'
import { SelectToggleDevice } from '../livekit/components/controls/SelectToggleDevice'
import { Field } from '@/primitives/Field'
import { Form } from '@/primitives'
import { HStack, VStack } from '@/styled-system/jsx'
const onError = (e: Error) => console.error('ERROR', e)
export const Join = ({
onSubmit,
}: {
onSubmit: (choices: LocalUserChoices) => void
}) => {
const { t } = useTranslation('rooms')
const { user } = useUser()
const { t } = useTranslation('rooms', { keyPrefix: 'join' })
const {
userChoices: initialUserChoices,
saveAudioInputDeviceId,
saveVideoInputDeviceId,
saveUsername,
} = usePersistentUserChoices({})
const [audioDeviceId, setAudioDeviceId] = useState<string>(
initialUserChoices.audioDeviceId
)
const [videoDeviceId, setVideoDeviceId] = useState<string>(
initialUserChoices.videoDeviceId
)
const [username, setUsername] = useState<string>(initialUserChoices.username)
useEffect(() => {
saveAudioInputDeviceId(audioDeviceId)
}, [audioDeviceId, saveAudioInputDeviceId])
useEffect(() => {
saveVideoInputDeviceId(videoDeviceId)
}, [videoDeviceId, saveVideoInputDeviceId])
useEffect(() => {
saveUsername(username)
}, [username, saveUsername])
const [audioEnabled, setAudioEnabled] = useState(true)
const [videoEnabled, setVideoEnabled] = useState(true)
const tracks = usePreviewTracks(
{
audio: { deviceId: initialUserChoices.audioDeviceId },
video: { deviceId: initialUserChoices.videoDeviceId },
},
onError
)
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 LocalVideoTrack,
[tracks]
)
const videoEl = useRef(null)
useEffect(() => {
const videoElement = videoEl.current as HTMLVideoElement | null
const handleVideoLoaded = () => {
if (videoElement) {
videoElement.style.opacity = '1'
}
}
if (videoElement && videoTrack && videoEnabled) {
videoTrack.unmute()
videoTrack.attach(videoElement)
videoElement.addEventListener('loadedmetadata', handleVideoLoaded)
}
return () => {
videoTrack?.detach()
videoElement?.removeEventListener('loadedmetadata', handleVideoLoaded)
}
}, [videoTrack, videoEnabled])
function handleSubmit() {
onSubmit({
audioEnabled,
videoEnabled,
audioDeviceId,
videoDeviceId,
username,
})
}
return (
<Screen layout="centered" footer={false}>
<CenteredContent title={t('join.heading')}>
<PreJoin
persistUserChoices
onSubmit={onSubmit}
micLabel={t('join.audioinput.label')}
camLabel={t('join.videoinput.label')}
joinLabel={t('join.joinLabel')}
userLabel={t('join.usernameLabel')}
defaults={{ username: user?.full_name }}
/>
</CenteredContent>
<Screen footer={false}>
<div
className={css({
display: 'flex',
alignItems: 'flex-start',
justifyContent: 'center',
flexGrow: 1,
lg: {
alignItems: 'center',
},
})}
>
<div
className={css({
display: 'flex',
height: 'auto',
alignItems: 'center',
justifyContent: 'center',
gap: '2rem',
padding: '0 2rem',
flexDirection: 'column',
minWidth: 0,
width: '100%',
lg: {
flexDirection: 'row',
width: 'auto',
height: '570px',
},
})}
>
<div
className={css({
width: '100%',
lg: {
width: '740px',
},
})}
>
<div
className={css({
borderRadius: '1rem',
overflow: 'hidden',
position: 'relative',
width: '100%',
height: 'auto',
aspectRatio: 16 / 9,
'--tw-shadow':
'0 10px 15px -5px #00000010, 0 4px 5px -6px #00000010',
'--tw-shadow-colored':
'0 10px 15px -5px var(--tw-shadow-color), 0 8px 10px -6px var(--tw-shadow-color)',
boxShadow:
'var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow)',
backgroundColor: 'black',
})}
>
{videoTrack && videoEnabled ? (
// eslint-disable-next-line jsx-a11y/media-has-caption
<video
ref={videoEl}
width="1280"
height="720"
className={css({
display: 'block',
width: '102%',
height: '102%',
objectFit: 'cover',
transform: 'rotateY(180deg)',
opacity: 0,
transition: 'opacity 0.3s ease-in-out',
})}
/>
) : (
<div
className={css({
width: '100%',
height: '100%',
color: 'white',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
})}
>
<p
className={css({
fontSize: '24px',
fontWeight: '300',
})}
>
{!videoEnabled && t('cameraDisabled')}
{videoEnabled && !videoTrack && t('cameraStarting')}
</p>
</div>
)}
</div>
<HStack justify="center" padding={1.5}>
<SelectToggleDevice
source={Track.Source.Microphone}
initialState={audioEnabled}
track={audioTrack}
initialDeviceId={initialUserChoices.audioDeviceId}
onChange={(enabled) => setAudioEnabled(enabled)}
onDeviceError={(error) => console.error(error)}
onActiveDeviceChange={(deviceId) =>
setAudioDeviceId(deviceId ?? '')
}
variant="tertiary"
/>
<SelectToggleDevice
source={Track.Source.Camera}
initialState={videoEnabled}
track={videoTrack}
initialDeviceId={initialUserChoices.videoDeviceId}
onChange={(enabled) => setVideoEnabled(enabled)}
onDeviceError={(error) => console.error(error)}
onActiveDeviceChange={(deviceId) =>
setVideoDeviceId(deviceId ?? '')
}
variant="tertiary"
/>
</HStack>
</div>
<div
className={css({
width: '100%',
flexShrink: 0,
padding: '0',
sm: {
width: '448px',
padding: '0 3rem 9rem 3rem',
},
})}
>
<Form
onSubmit={handleSubmit}
submitLabel={t('joinLabel')}
submitButtonProps={{
fullWidth: true,
}}
>
<VStack marginBottom={1}>
<H lvl={1} margin={false}>
{t('heading')}
</H>
<Field
type="text"
onChange={setUsername}
label={t('usernameLabel')}
aria-label={t('usernameLabel')}
defaultValue={initialUserChoices?.username}
validate={(value) => !value && t('errors.usernameEmpty')}
wrapperProps={{
noMargin: true,
fullWidth: true,
}}
labelProps={{
center: true,
}}
maxLength={50}
/>
</VStack>
</Form>
</div>
</div>
</div>
</Screen>
)
}
@@ -0,0 +1,20 @@
import { RiImageCircleAiFill } from '@remixicon/react'
import { MenuItem } from 'react-aria-components'
import { useTranslation } from 'react-i18next'
import { menuRecipe } from '@/primitives/menuRecipe'
import { useSidePanel } from '../../../hooks/useSidePanel'
export const EffectsMenuItem = () => {
const { t } = useTranslation('rooms', { keyPrefix: 'options.items' })
const { toggleEffects } = useSidePanel()
return (
<MenuItem
onAction={() => toggleEffects()}
className={menuRecipe({ icon: true, variant: 'dark' }).item}
>
<RiImageCircleAiFill size={20} />
{t('effects')}
</MenuItem>
)
}
@@ -0,0 +1,20 @@
import { RiMegaphoneLine } from '@remixicon/react'
import { MenuItem } from 'react-aria-components'
import { useTranslation } from 'react-i18next'
import { menuRecipe } from '@/primitives/menuRecipe'
import { GRIST_FORM } from '@/utils/constants'
export const FeedbackMenuItem = () => {
const { t } = useTranslation('rooms', { keyPrefix: 'options.items' })
return (
<MenuItem
href={GRIST_FORM}
target="_blank"
className={menuRecipe({ icon: true, variant: 'dark' }).item}
>
<RiMegaphoneLine size={20} />
{t('feedback')}
</MenuItem>
)
}
@@ -0,0 +1,34 @@
import { RiFullscreenExitLine, RiFullscreenLine } from '@remixicon/react'
import { MenuItem } from 'react-aria-components'
import { useTranslation } from 'react-i18next'
import { menuRecipe } from '@/primitives/menuRecipe'
import { useFullScreen } from '../../../hooks/useFullScreen'
export const FullScreenMenuItem = () => {
const { t } = useTranslation('rooms', { keyPrefix: 'options.items' })
const { toggleFullScreen, isCurrentlyFullscreen, isFullscreenAvailable } =
useFullScreen()
if (!isFullscreenAvailable) {
return null
}
return (
<MenuItem
onAction={() => toggleFullScreen()}
className={menuRecipe({ icon: true, variant: 'dark' }).item}
>
{isCurrentlyFullscreen ? (
<>
<RiFullscreenExitLine size={20} />
{t('fullscreen.exit')}
</>
) : (
<>
<RiFullscreenLine size={20} />
{t('fullscreen.enter')}
</>
)}
</MenuItem>
)
}
@@ -1,21 +1,12 @@
import {
RiAccountBoxLine,
RiMegaphoneLine,
RiSettings3Line,
} from '@remixicon/react'
import { MenuItem, Menu as RACMenu, MenuSection } from 'react-aria-components'
import { useTranslation } from 'react-i18next'
import { Menu as RACMenu, MenuSection } from 'react-aria-components'
import { Separator } from '@/primitives/Separator'
import { useSidePanel } from '../../../hooks/useSidePanel'
import { menuRecipe } from '@/primitives/menuRecipe.ts'
import { useSettingsDialog } from '../SettingsDialogContext'
import { GRIST_FORM } from '@/utils/constants'
import { FullScreenMenuItem } from './FullScreenMenuItem'
import { SettingsMenuItem } from './SettingsMenuItem'
import { FeedbackMenuItem } from './FeedbackMenuItem'
import { EffectsMenuItem } from './EffectsMenuItem'
// @todo try refactoring it to use MenuList component
export const OptionsMenuItems = () => {
const { t } = useTranslation('rooms', { keyPrefix: 'options.items' })
const { toggleEffects } = useSidePanel()
const { setDialogOpen } = useSettingsDialog()
return (
<RACMenu
style={{
@@ -24,31 +15,13 @@ export const OptionsMenuItems = () => {
}}
>
<MenuSection>
<MenuItem
onAction={() => toggleEffects()}
className={menuRecipe({ icon: true, variant: 'dark' }).item}
>
<RiAccountBoxLine size={20} />
{t('effects')}
</MenuItem>
<FullScreenMenuItem />
<EffectsMenuItem />
</MenuSection>
<Separator />
<MenuSection>
<MenuItem
href={GRIST_FORM}
target="_blank"
className={menuRecipe({ icon: true, variant: 'dark' }).item}
>
<RiMegaphoneLine size={20} />
{t('feedback')}
</MenuItem>
<MenuItem
className={menuRecipe({ icon: true, variant: 'dark' }).item}
onAction={() => setDialogOpen(true)}
>
<RiSettings3Line size={20} />
{t('settings')}
</MenuItem>
<FeedbackMenuItem />
<SettingsMenuItem />
</MenuSection>
</RACMenu>
)
@@ -0,0 +1,20 @@
import { RiSettings3Line } from '@remixicon/react'
import { MenuItem } from 'react-aria-components'
import { useTranslation } from 'react-i18next'
import { menuRecipe } from '@/primitives/menuRecipe'
import { useSettingsDialog } from '../SettingsDialogContext'
export const SettingsMenuItem = () => {
const { t } = useTranslation('rooms', { keyPrefix: 'options.items' })
const { setDialogOpen } = useSettingsDialog()
return (
<MenuItem
className={menuRecipe({ icon: true, variant: 'dark' }).item}
onAction={() => setDialogOpen(true)}
>
<RiSettings3Line size={20} />
{t('settings')}
</MenuItem>
)
}
@@ -13,12 +13,14 @@ import {
RiVideoOffLine,
RiVideoOnLine,
} from '@remixicon/react'
import { Track } from 'livekit-client'
import { LocalAudioTrack, LocalVideoTrack, Track } from 'livekit-client'
import { Shortcut } from '@/features/shortcuts/types'
import { ToggleDevice } from '@/features/rooms/livekit/components/controls/ToggleDevice.tsx'
import { css } from '@/styled-system/css'
import { ButtonRecipeProps } from '@/primitives/buttonRecipe'
import { useEffect } from 'react'
export type ToggleSource = Exclude<
Track.Source,
@@ -65,16 +67,22 @@ const selectToggleDeviceConfig: SelectToggleDeviceConfigMap = {
type SelectToggleDeviceProps<T extends ToggleSource> =
UseTrackToggleProps<T> & {
track?: LocalAudioTrack | LocalVideoTrack | undefined
initialDeviceId?: string
onActiveDeviceChange: (deviceId: string) => void
source: SelectToggleSource
variant?: NonNullable<ButtonRecipeProps>['variant']
menuVariant?: 'dark' | 'light'
hideMenu?: boolean
variant?: 'dark' | 'light'
}
export const SelectToggleDevice = <T extends ToggleSource>({
track,
initialDeviceId,
onActiveDeviceChange,
hideMenu,
variant = 'light',
variant = 'primaryDark',
menuVariant = 'light',
...props
}: SelectToggleDeviceProps<T>) => {
const config = selectToggleDeviceConfig[props.source]
@@ -85,7 +93,19 @@ export const SelectToggleDevice = <T extends ToggleSource>({
const trackProps = useTrackToggle(props)
const { devices, activeDeviceId, setActiveMediaDevice } =
useMediaDeviceSelect({ kind: config.kind })
useMediaDeviceSelect({ kind: config.kind, track })
/**
* When providing only track outside of a room context, activeDeviceId is undefined.
* So we need to initialize it with the initialDeviceId.
* nb: I don't understand why useMediaDeviceSelect cannot infer it from track device id.
*/
useEffect(() => {
if (initialDeviceId !== undefined) {
setActiveMediaDevice(initialDeviceId)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [setActiveMediaDevice])
const selectLabel = t('choose', { keyPrefix: `join.${config.kind}` })
@@ -99,6 +119,7 @@ export const SelectToggleDevice = <T extends ToggleSource>({
<ToggleDevice
{...trackProps}
config={config}
variant={variant}
toggleButtonProps={{
...(hideMenu
? {
@@ -108,13 +129,13 @@ export const SelectToggleDevice = <T extends ToggleSource>({
}}
/>
{!hideMenu && (
<Menu variant={variant}>
<Menu variant={menuVariant}>
<Button
tooltip={selectLabel}
aria-label={selectLabel}
groupPosition="right"
square
variant={trackProps.enabled ? 'primaryDark' : 'error2'}
variant={trackProps.enabled ? variant : 'error2'}
>
<RiArrowDownSLine />
</Button>
@@ -128,7 +149,7 @@ export const SelectToggleDevice = <T extends ToggleSource>({
setActiveMediaDevice(value as string)
onActiveDeviceChange(value as string)
}}
variant={variant}
variant={menuVariant}
/>
</Menu>
)}
@@ -4,10 +4,14 @@ import { useTranslation } from 'react-i18next'
import { Crisp } from 'crisp-sdk-web'
import { useEffect, useState } from 'react'
import { ToggleButtonProps } from '@/primitives/ToggleButton'
import { useIsSupportEnabled } from '@/features/support/hooks/useSupport'
export const SupportToggle = ({ onPress, ...props }: ToggleButtonProps) => {
const { t } = useTranslation('rooms', { keyPrefix: 'controls' })
const [isOpened, setIsOpened] = useState($crisp.is('chat:opened'))
const [isOpened, setIsOpened] = useState(() => {
return window?.$crisp?.is?.('chat:opened') || false
})
useEffect(() => {
if (!Crisp) {
@@ -26,6 +30,12 @@ export const SupportToggle = ({ onPress, ...props }: ToggleButtonProps) => {
}
}, [])
const isSupportEnabled = useIsSupportEnabled()
if (!isSupportEnabled) {
return
}
return (
<ToggleButton
square
@@ -6,13 +6,19 @@ import { useTranslation } from 'react-i18next'
import { SelectToggleDeviceConfig } from './SelectToggleDevice'
import useLongPress from '@/features/shortcuts/useLongPress'
import { ActiveSpeaker } from '@/features/rooms/components/ActiveSpeaker'
import { useIsSpeaking, useLocalParticipant } from '@livekit/components-react'
import {
useIsSpeaking,
useLocalParticipant,
useMaybeRoomContext,
} from '@livekit/components-react'
import { ButtonRecipeProps } from '@/primitives/buttonRecipe'
import { ToggleButtonProps } from '@/primitives/ToggleButton'
export type ToggleDeviceProps = {
enabled: boolean
toggle: () => void
config: SelectToggleDeviceConfig
variant?: NonNullable<ButtonRecipeProps>['variant']
toggleButtonProps?: Partial<ToggleButtonProps>
}
@@ -20,6 +26,7 @@ export const ToggleDevice = ({
config,
enabled,
toggle,
variant = 'primaryDark',
toggleButtonProps,
}: ToggleDeviceProps) => {
const { t } = useTranslation('rooms', { keyPrefix: 'join' })
@@ -51,17 +58,15 @@ export const ToggleDevice = ({
const Icon = enabled ? iconOn : iconOff
const { localParticipant } = useLocalParticipant()
const isSpeaking = useIsSpeaking(localParticipant)
if (kind === 'audioinput' && pushToTalk) {
return <ActiveSpeaker isSpeaking={isSpeaking} pushToTalk />
const context = useMaybeRoomContext()
if (kind === 'audioinput' && pushToTalk && context) {
return <ActiveSpeakerWrapper />
}
return (
<ToggleButton
isSelected={!enabled}
variant={enabled ? 'primaryDark' : 'error2'}
variant={enabled ? variant : 'error2'}
shySelected
onPress={() => toggle()}
aria-label={toggleLabel}
@@ -73,3 +78,9 @@ export const ToggleDevice = ({
</ToggleButton>
)
}
const ActiveSpeakerWrapper = () => {
const { localParticipant } = useLocalParticipant()
const isSpeaking = useIsSpeaking(localParticipant)
return <ActiveSpeaker isSpeaking={isSpeaking} pushToTalk />
}
@@ -0,0 +1,68 @@
// We use vendor prefix properties
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-nocheck
import { useState } from 'react'
export function useFullScreen() {
const getIsFullscreen = () => {
return !!(
document.fullscreenElement ||
document.webkitFullscreenElement ||
document.mozFullScreenElement ||
document.msFullscreenElement
)
}
const [isFullscreenAvailable] = useState(
() =>
document.fullscreenEnabled ||
document.webkitFullscreenEnabled ||
document.mozFullScreenEnabled ||
document.msFullscreenEnabled
)
const enterFullscreen = async () => {
try {
const docEl = document.documentElement
if (docEl.requestFullscreen) {
await docEl.requestFullscreen()
} else if (docEl.webkitRequestFullscreen) {
await docEl.webkitRequestFullscreen()
} else if (docEl.msRequestFullscreen) {
await docEl.msRequestFullscreen()
}
} catch (error) {
console.error('Error entering fullscreen:', error)
}
}
const exitFullscreen = async () => {
try {
if (document.exitFullscreen) {
await document.exitFullscreen()
} else if (document.webkitExitFullscreen) {
await document.webkitExitFullscreen()
} else if (document.msExitFullscreen) {
await document.msExitFullscreen()
}
} catch (error) {
console.error('Error exiting fullscreen:', error)
}
}
const toggleFullScreen = async () => {
const isCurrentlyFullscreen = getIsFullscreen()
if (isCurrentlyFullscreen) {
await exitFullscreen()
} else {
await enterFullscreen()
}
}
return {
isCurrentlyFullscreen: getIsFullscreen(),
isFullscreenAvailable,
toggleFullScreen,
}
}
@@ -62,7 +62,7 @@ export function DesktopControlBar({
onActiveDeviceChange={(deviceId) =>
saveAudioInputDeviceId(deviceId ?? '')
}
variant="dark"
menuVariant="dark"
/>
<SelectToggleDevice
source={Track.Source.Camera}
@@ -73,7 +73,7 @@ export function DesktopControlBar({
onActiveDeviceChange={(deviceId) =>
saveVideoInputDeviceId(deviceId ?? '')
}
variant="dark"
menuVariant="dark"
/>
{browserSupportsScreenSharing && (
<ScreenShareToggle
@@ -1,6 +1,7 @@
import { useEffect } from 'react'
import { Crisp } from 'crisp-sdk-web'
import { ApiUser } from '@/features/auth/api/ApiUser'
import { useConfig } from '@/api/useConfig'
export const initializeSupportSession = (user: ApiUser) => {
if (!Crisp.isCrispInjected()) return
@@ -29,3 +30,8 @@ export const useSupport = ({ id }: useSupportProps) => {
return null
}
export const useIsSupportEnabled = () => {
const { data } = useConfig()
return !!data?.support?.id
}
+11 -2
View File
@@ -24,7 +24,12 @@
"toggleOff": "",
"toggleOn": "",
"usernameHint": "",
"usernameLabel": ""
"usernameLabel": "",
"errors": {
"usernameEmpty": ""
},
"cameraDisabled": "",
"cameraStarting": ""
},
"leaveRoomPrompt": "",
"shareDialog": {
@@ -85,7 +90,11 @@
"settings": "",
"username": "",
"effects": "",
"switchCamera": ""
"switchCamera": "",
"fullscreen": {
"enter": "",
"exit": ""
}
}
},
"effects": {
+12 -3
View File
@@ -18,13 +18,18 @@
"enable": "Enable microphone",
"label": "Microphone"
},
"heading": "Verify your settings",
"heading": "Join the meeting",
"joinLabel": "Join",
"joinMeeting": "Join meeting",
"toggleOff": "Click to turn off",
"toggleOn": "Click to turn on",
"usernameHint": "Shown to other participants",
"usernameLabel": "Your name"
"usernameLabel": "Your name",
"errors": {
"usernameEmpty": "Your name cannot be empty"
},
"cameraDisabled": "Camera is disabled.",
"cameraStarting": "Camera is starting."
},
"leaveRoomPrompt": "This will make you leave the meeting.",
"shareDialog": {
@@ -84,7 +89,11 @@
"settings": "Settings",
"username": "Update Your Name",
"effects": "Apply effects",
"switchCamera": "Switch camera"
"switchCamera": "Switch camera",
"fullscreen": {
"enter": "Fullscreen",
"exit": "Exit fullscreen mode"
}
}
},
"effects": {
+12 -3
View File
@@ -18,13 +18,18 @@
"enable": "Activer le micro",
"label": "Microphone"
},
"heading": "Vérifiez vos paramètres",
"heading": "Rejoindre la réunion",
"joinLabel": "Rejoindre",
"joinMeeting": "Rejoindre la réjoindre",
"toggleOff": "Cliquez pour désactiver",
"toggleOn": "Cliquez pour activer",
"usernameHint": "Affiché aux autres participants",
"usernameLabel": "Votre nom"
"usernameLabel": "Votre nom",
"errors": {
"usernameEmpty": "Votre nom ne peut pas être vide"
},
"cameraDisabled": "La caméra est désactivée.",
"cameraStarting": "La caméra va démarrer."
},
"leaveRoomPrompt": "Revenir à l'accueil vous fera quitter la réunion.",
"shareDialog": {
@@ -84,7 +89,11 @@
"settings": "Paramètres",
"username": "Choisir votre nom",
"effects": "Appliquer des effets",
"switchCamera": "Changer de caméra"
"switchCamera": "Changer de caméra",
"fullscreen": {
"enter": "Plein écran",
"exit": "Quitter le mode plein écran"
}
}
},
"effects": {
+28 -6
View File
@@ -25,11 +25,31 @@ const FieldWrapper = styled('div', {
marginBottom: 'textfield',
minWidth: 0,
},
variants: {
noMargin: {
true: {
marginBottom: 0,
},
},
fullWidth: {
true: {
width: '100%',
},
},
},
})
const StyledLabel = styled(Label, {
base: {
display: 'block',
fontSize: '0.75rem',
},
variants: {
center: {
true: {
textAlign: 'center',
},
},
},
})
@@ -80,6 +100,8 @@ type FieldProps<T extends object> = (
) & {
label: string
description?: string
wrapperProps?: React.ComponentProps<typeof FieldWrapper>
labelProps?: React.ComponentProps<typeof StyledLabel>
}
/**
@@ -104,7 +126,7 @@ export const Field = <T extends object>({
}: FieldProps<T>) => {
const LabelAndDescription = (
<>
<StyledLabel>{label}</StyledLabel>
<StyledLabel {...props.labelProps}>{label}</StyledLabel>
<FieldDescription slot="description">{description}</FieldDescription>
</>
)
@@ -118,7 +140,7 @@ export const Field = <T extends object>({
if (type === 'text') {
return (
<FieldWrapper>
<FieldWrapper {...props.wrapperProps}>
<RACTextField
validate={validate as unknown as TextFieldProps['validate']}
{...(props as PartialTextFieldProps)}
@@ -133,7 +155,7 @@ export const Field = <T extends object>({
if (type === 'checkbox') {
return (
<FieldWrapper>
<FieldWrapper {...props.wrapperProps}>
<Checkbox
validate={validate as unknown as CheckboxProps['validate']}
description={description}
@@ -147,7 +169,7 @@ export const Field = <T extends object>({
if (type === 'checkboxGroup') {
return (
<FieldWrapper>
<FieldWrapper {...props.wrapperProps}>
<CheckboxGroup
validate={validate as unknown as CheckboxGroupProps['validate']}
{...(props as PartialCheckboxGroupProps)}
@@ -170,7 +192,7 @@ export const Field = <T extends object>({
if (type === 'radioGroup') {
return (
<FieldWrapper>
<FieldWrapper {...props.wrapperProps}>
<RadioGroup
validate={validate as unknown as RadioGroupProps['validate']}
{...(props as PartialRadioGroupProps)}
@@ -189,7 +211,7 @@ export const Field = <T extends object>({
if (type === 'select') {
return (
<FieldWrapper>
<FieldWrapper {...props.wrapperProps}>
<Select
validate={validate as unknown as SelectProps<T>['validate']}
{...(props as PartialSelectProps<T>)}
+4 -1
View File
@@ -3,6 +3,7 @@ import { Form as RACForm, type FormProps } from 'react-aria-components'
import { useTranslation } from 'react-i18next'
import { HStack } from '@/styled-system/jsx'
import { Button, useCloseDialog } from '@/primitives'
import { ButtonProps } from '@/primitives/Button'
/**
* From wrapper that exposes form data on submit and adds submit/cancel buttons
@@ -13,6 +14,7 @@ import { Button, useCloseDialog } from '@/primitives'
export const Form = ({
onSubmit,
submitLabel,
submitButtonProps,
withCancelButton = true,
onCancelButtonPress,
children,
@@ -25,6 +27,7 @@ export const Form = ({
event: FormEvent<HTMLFormElement>
) => void
submitLabel: string
submitButtonProps?: ButtonProps
withCancelButton?: boolean
onCancelButtonPress?: () => void
}) => {
@@ -47,7 +50,7 @@ export const Form = ({
>
{children}
<HStack gap="gutter">
<Button type="submit" variant="primary">
<Button type="submit" variant="primary" {...submitButtonProps}>
{submitLabel}
</Button>
{!!onCancel && (
+4 -2
View File
@@ -1,10 +1,12 @@
import { Text } from './Text'
import { Text, TextProps } from './Text'
export const H = ({
children,
lvl,
...props
}: React.HTMLAttributes<HTMLHeadingElement> & { lvl: 1 | 2 | 3 }) => {
}: React.HTMLAttributes<HTMLHeadingElement> & {
lvl: 1 | 2 | 3
} & TextProps) => {
const tag = `h${lvl}` as const
return (
<Text as={tag} variant={tag} {...props}>
+1
View File
@@ -10,6 +10,7 @@ export default defineConfig(({ mode }) => {
server: {
port: parseInt(env.VITE_PORT) || 3000,
host: env.VITE_HOST || 'localhost',
allowedHosts: ['.nip.io'],
},
}
})
+1 -1
View File
@@ -14,7 +14,7 @@
"build": "npm run build-mjml-to-html && npm run build-html-to-plain-text"
},
"volta": {
"node": "22.13.0"
"node": "22.13.1"
},
"repository": "https://github.com/numerique-gouv/meet",
"author": "DINUM",
+2 -2
View File
@@ -10,14 +10,14 @@ dependencies = [
"celery==5.4.0",
"redis==5.2.1",
"minio==7.2.15",
"openai==1.59.8",
"openai==1.60.2",
"requests==2.32.3",
"sentry-sdk[fastapi, celery]==2.20.0",
]
[project.optional-dependencies]
dev = [
"ruff==0.9.2",
"ruff==0.9.3",
]
[build-system]