Compare commits

..

16 Commits

Author SHA1 Message Date
Nathan Vasse 24509c96d1 (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:01:21 +01:00
Nathan Vasse 88eddd85d7 (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 14:58:10 +01:00
Nathan Vasse f3eeeff96f (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 14:55:42 +01:00
Nathan Vasse 1e339023b3 (front) add customization to SelectToggleDevice
We want to be able to change the variant of this component.
2025-01-27 14:55:41 +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
Nathan Vasse 96c18fc627 (front) add switch camera on mobile
We want to have a more simple control bar and give the user to switch
its back / front camera from the responsive menu.
2025-01-21 15:37:59 +01:00
Nathan Vasse c380ff5e1d (front) option to hide menu on SelectToggleDevice
On mobile we want to be able to hide the chevron and menu.
2025-01-21 15:37:59 +01:00
lebaudantoine 6587863afb 💄(frontend) use dark theme for menus in the videoconference
Inspired by GMeet. Actually, these menus are horrible to work with.
This is clearly some technical debt. I fixed the styles, but not the
code, we should refactor them to make easy to chose between two
variant, a light and a dark one.
2025-01-21 11:53:12 +01:00
lebaudantoine a06daad33d 💄(frontend) fix screen-share control alignment
Oopsie, due to its (too) big icon, the height of this control was 4px bigger
than other buttons. Fixed that minor issue.
2025-01-21 11:53:12 +01:00
renovate[bot] 10c3c67d53 ⬆️(dependencies) update js dependencies 2025-01-21 11:20:29 +01:00
lebaudantoine fa85ef5690 📌(frontend) block upgrade to react v19
Many dependencies are not supported react v19 yet.
Avoid renovate to bump react to its latest major version.
2025-01-21 11:20:29 +01:00
lebaudantoine cc676cf71b ⬆️(frontend) bump react-aria-related dependencies
No major / breaking change in these few releases.
Quickly tested all features, everything seems alright
2025-01-20 19:31:55 +01:00
lebaudantoine 90df8fe5b4 ⬆️(frontend) bump react-query-related dependencies
No major / breaking change in these few releases.
Quickly tested all features, everything seems alright
2025-01-20 19:31:55 +01:00
lebaudantoine 90cfcd31c8 ⬆️(frontend) bump LiveKit-related dependencies
No major / breaking change in these few releases.
Quickly tested all features, everything seems alright
2025-01-20 19:31:55 +01:00
renovate[bot] 0ed3416ce2 ⬆️(dependencies) update python dependencies 2025-01-20 09:35:06 +01:00
lebaudantoine e47401246f ✏️(frontend) fix feedback-s
Feedback is a mass noun.
Spotted by @bvhme
2025-01-18 22:11:35 +01:00
25 changed files with 2489 additions and 1548 deletions
+1 -1
View File
@@ -14,7 +14,7 @@
"groupName": "ignored js dependencies",
"matchManagers": ["npm"],
"matchPackageNames": [
"eslint"
"eslint", "react", "react-dom", "@types/react-dom", "@types/react", "react-i18next"
]
}
]
+6 -6
View File
@@ -25,7 +25,7 @@ license = { file = "LICENSE" }
readme = "README.md"
requires-python = ">=3.10"
dependencies = [
"boto3==1.35.97",
"boto3==1.36.2",
"Brotli==1.1.0",
"brevo-python==1.1.2",
"celery[redis]==5.4.0",
@@ -48,11 +48,11 @@ dependencies = [
"june-analytics-python==2.3.0",
"markdown==3.7",
"nested-multipart-parser==1.5.0",
"psycopg[binary]==3.2.3",
"psycopg[binary]==3.2.4",
"PyJWT==2.10.1",
"python-frontmatter==1.1.0",
"requests==2.32.3",
"sentry-sdk==2.19.2",
"sentry-sdk==2.20.0",
"url-normalize==1.4.3",
"WeasyPrint>=60.2",
"whitenoise==6.8.2",
@@ -74,7 +74,7 @@ dev = [
"freezegun==1.5.1",
"ipdb==0.13.13",
"ipython==8.31.0",
"pyfakefs==5.7.3",
"pyfakefs==5.7.4",
"pylint-django==2.6.1",
"pylint==3.3.3",
"pytest-cov==6.0.0",
@@ -82,8 +82,8 @@ dev = [
"pytest==8.3.4",
"pytest-icdiff==0.9",
"pytest-xdist==3.6.1",
"responses==0.25.5",
"ruff==0.9.1",
"responses==0.25.6",
"ruff==0.9.2",
"types-requests==2.32.0.20241016",
]
+1796 -1434
View File
File diff suppressed because it is too large Load Diff
+26 -26
View File
@@ -13,23 +13,23 @@
"check": "prettier --check ./src"
},
"dependencies": {
"@livekit/components-react": "2.6.9",
"@livekit/components-react": "2.8.0",
"@livekit/components-styles": "1.1.4",
"@livekit/track-processors": "0.3.2",
"@pandacss/preset-panda": "0.48.0",
"@react-aria/toast": "3.0.0-beta.18",
"@remixicon/react": "4.5.0",
"@tanstack/react-query": "5.61.3",
"@livekit/track-processors": "0.3.3",
"@pandacss/preset-panda": "0.51.1",
"@react-aria/toast": "3.0.0-beta.19",
"@remixicon/react": "4.6.0",
"@tanstack/react-query": "5.64.2",
"crisp-sdk-web": "1.0.25",
"hoofd": "1.7.1",
"i18next": "24.0.2",
"i18next-browser-languagedetector": "8.0.0",
"i18next-parser": "9.0.2",
"hoofd": "1.7.3",
"i18next": "24.2.1",
"i18next-browser-languagedetector": "8.0.2",
"i18next-parser": "9.1.0",
"i18next-resources-to-backend": "1.2.1",
"livekit-client": "2.6.3",
"posthog-js": "1.188.0",
"livekit-client": "2.8.1",
"posthog-js": "1.207.0",
"react": "18.3.1",
"react-aria-components": "1.5.0",
"react-aria-components": "1.6.0",
"react-dom": "18.3.1",
"react-i18next": "15.1.1",
"use-sound": "4.0.3",
@@ -37,24 +37,24 @@
"wouter": "3.3.5"
},
"devDependencies": {
"@pandacss/dev": "0.48.0",
"@tanstack/eslint-plugin-query": "5.61.3",
"@tanstack/react-query-devtools": "5.61.3",
"@types/node": "22.9.3",
"@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/react": "18.3.12",
"@types/react-dom": "18.3.1",
"@typescript-eslint/eslint-plugin": "8.15.0",
"@typescript-eslint/parser": "8.15.0",
"@typescript-eslint/eslint-plugin": "8.21.0",
"@typescript-eslint/parser": "8.21.0",
"@vitejs/plugin-react": "4.3.4",
"eslint": "8.57.0",
"eslint-config-prettier": "9.1.0",
"eslint-config-prettier": "10.0.1",
"eslint-plugin-jsx-a11y": "6.10.2",
"eslint-plugin-react-hooks": "5.0.0",
"eslint-plugin-react-refresh": "0.4.14",
"postcss": "8.4.49",
"prettier": "3.3.3",
"typescript": "5.7.2",
"vite": "6.0.7",
"eslint-plugin-react-hooks": "5.1.0",
"eslint-plugin-react-refresh": "0.4.18",
"postcss": "8.5.1",
"prettier": "3.4.2",
"typescript": "5.7.3",
"vite": "6.0.10",
"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,8 +1,27 @@
import { useTranslation } from 'react-i18next'
import { PreJoin, type LocalUserChoices } from '@livekit/components-react'
import {
ParticipantPlaceholder,
usePersistentUserChoices,
usePreviewTracks,
type LocalUserChoices,
} from '@livekit/components-react'
import { css } from '@/styled-system/css'
import { log } from '@livekit/components-core'
import { defaultUserChoices } from '@livekit/components-core'
import { Screen } from '@/layout/Screen'
import { CenteredContent } from '@/layout/CenteredContent'
import { useUser } from '@/features/auth'
import React from 'react'
import {
facingModeFromLocalTrack,
LocalVideoTrack,
Track,
} from 'livekit-client'
import { H } from '@/primitives/H'
import { SelectToggleDevice } from '../livekit/components/controls/SelectToggleDevice'
import { Field } from '@/primitives/Field'
import { Button } from '@/primitives'
const onError = (e: Error) => console.error('ERROR', e)
export const Join = ({
onSubmit,
@@ -11,20 +30,349 @@ export const Join = ({
}) => {
const { t } = useTranslation('rooms')
const { user } = useUser()
const defaults: Partial<LocalUserChoices> = { username: user?.full_name }
const persistUserChoices = true
const joinLabel = t('join.joinLabel')
const userLabel = t('join.usernameLabel')
const [userChoices, setUserChoices] = React.useState(defaultUserChoices)
// TODO: Remove and pipe `defaults` object directly into `usePersistentUserChoices` once we fully switch from type `LocalUserChoices` to `UserChoices`.
const partialDefaults: Partial<LocalUserChoices> = {
...(defaults.audioDeviceId !== undefined && {
audioDeviceId: defaults.audioDeviceId,
}),
...(defaults.videoDeviceId !== undefined && {
videoDeviceId: defaults.videoDeviceId,
}),
...(defaults.audioEnabled !== undefined && {
audioEnabled: defaults.audioEnabled,
}),
...(defaults.videoEnabled !== undefined && {
videoEnabled: defaults.videoEnabled,
}),
...(defaults.username !== undefined && { username: defaults.username }),
}
const {
userChoices: initialUserChoices,
saveAudioInputDeviceId,
saveAudioInputEnabled,
saveVideoInputDeviceId,
saveVideoInputEnabled,
saveUsername,
} = usePersistentUserChoices({
defaults: partialDefaults,
preventSave: !persistUserChoices,
preventLoad: !persistUserChoices,
})
// Initialize device settings
const [audioEnabled, setAudioEnabled] = React.useState<boolean>(
initialUserChoices.audioEnabled
)
const [videoEnabled, setVideoEnabled] = React.useState<boolean>(
initialUserChoices.videoEnabled
)
const [audioDeviceId, setAudioDeviceId] = React.useState<string>(
initialUserChoices.audioDeviceId
)
const [videoDeviceId, setVideoDeviceId] = React.useState<string>(
initialUserChoices.videoDeviceId
)
const [username, setUsername] = React.useState(initialUserChoices.username)
// Save user choices to persistent storage.
React.useEffect(() => {
saveAudioInputEnabled(audioEnabled)
}, [audioEnabled, saveAudioInputEnabled])
React.useEffect(() => {
saveVideoInputEnabled(videoEnabled)
}, [videoEnabled, saveVideoInputEnabled])
React.useEffect(() => {
saveAudioInputDeviceId(audioDeviceId)
}, [audioDeviceId, saveAudioInputDeviceId])
React.useEffect(() => {
saveVideoInputDeviceId(videoDeviceId)
}, [videoDeviceId, saveVideoInputDeviceId])
React.useEffect(() => {
saveUsername(username)
}, [username, saveUsername])
const tracks = usePreviewTracks(
{
audio: audioEnabled
? { deviceId: initialUserChoices.audioDeviceId }
: false,
video: videoEnabled
? { deviceId: initialUserChoices.videoDeviceId }
: false,
},
onError
)
const videoEl = React.useRef(null)
const videoTrack = React.useMemo(
() =>
tracks?.filter(
(track) => track.kind === Track.Kind.Video
)[0] as LocalVideoTrack,
[tracks]
)
const audioTrack = React.useMemo(
() =>
tracks?.filter(
(track) => track.kind === Track.Kind.Audio
)[0] as LocalVideoTrack,
[tracks]
)
const facingMode = React.useMemo(() => {
if (videoTrack) {
const { facingMode } = facingModeFromLocalTrack(videoTrack)
return facingMode
} else {
return 'undefined'
}
}, [videoTrack])
React.useEffect(() => {
if (videoEl.current && videoTrack) {
videoTrack.unmute()
videoTrack.attach(videoEl.current)
}
return () => {
videoTrack?.detach()
}
}, [videoTrack])
const [isValid, setIsValid] = React.useState<boolean>()
const handleValidation = React.useCallback((values: LocalUserChoices) => {
return values.username !== ''
}, [])
React.useEffect(() => {
const newUserChoices = {
username,
videoEnabled,
videoDeviceId,
audioEnabled,
audioDeviceId,
}
setUserChoices(newUserChoices)
setIsValid(handleValidation(newUserChoices))
}, [
username,
videoEnabled,
handleValidation,
audioEnabled,
audioDeviceId,
videoDeviceId,
])
function handleSubmit() {
if (handleValidation(userChoices)) {
if (typeof onSubmit === 'function') {
onSubmit(userChoices)
}
} else {
log.warn('Validation failed with: ', userChoices)
}
}
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 20px 25px -5px #0000001a, 0 8px 10px -6px #0000001a',
'--tw-shadow-colored':
'0 20px 25px -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)',
})}
>
{videoTrack && (
// eslint-disable-next-line jsx-a11y/media-has-caption
<video
ref={videoEl}
width="1280"
height="720"
data-lk-facing-mode={facingMode}
className={css({
display: 'block',
width: '100%',
height: '100%',
objectFit: 'cover',
transform: 'rotateY(180deg)',
})}
/>
)}
{(!videoTrack || !videoEnabled) && (
<div
id="container"
className={css({
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: '#000',
display: 'grid',
placeItems: 'center',
})}
>
<ParticipantPlaceholder
className={css({
maxWidth: '100%',
height: '70%',
})}
/>
</div>
)}
<div className="lk-button-group-container"></div>
</div>
<div
className={css({
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
padding: '1.5rem',
gap: '1rem',
})}
>
<SelectToggleDevice
source={Track.Source.Microphone}
initialState={audioEnabled}
track={audioTrack}
initialDeviceId={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={videoDeviceId}
onChange={(enabled) => {
setVideoEnabled(enabled)
}}
onDeviceError={(error) => console.error(error)}
onActiveDeviceChange={(deviceId) =>
setVideoDeviceId(deviceId ?? '')
}
variant="tertiary"
/>
</div>
</div>
<div
className={css({
width: '100%',
flexShrink: 0,
padding: '0',
sm: {
width: '448px',
padding: '0 3rem 9rem 3rem',
},
})}
>
<form
className={css({
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
alignItems: 'center',
gap: '1rem',
})}
>
<H lvl={1} className={css({ marginBottom: 0 })}>
{t('join.heading')}
</H>
<Field
type="text"
label={userLabel}
defaultValue={username}
onChange={(value) => setUsername(value)}
validate={(value) => {
return !value ? <p>{t('join.errors.usernameEmpty')}</p> : null
}}
className={css({
width: '100%',
})}
wrapperProps={{
noMargin: true,
fullWidth: true,
}}
labelProps={{
center: true,
}}
maxLength={50}
/>
<Button
type="submit"
variant={'primary'}
onPress={handleSubmit}
isDisabled={!isValid}
fullWidth
>
{joinLabel}
</Button>
</form>
</div>
</div>
</div>
</Screen>
)
}
@@ -0,0 +1,123 @@
import { Button } from '@/primitives'
import { useMediaDeviceSelect } from '@livekit/components-react'
import { RiCameraSwitchLine } from '@remixicon/react'
import { useEffect, useState } from 'react'
import { ButtonProps } from 'react-aria-components'
import { useTranslation } from 'react-i18next'
enum FacingMode {
USER = 'user',
ENVIRONMENT = 'environment',
}
export const CameraSwitchButton = (props: Partial<ButtonProps>) => {
const { t } = useTranslation('rooms')
const { devices, activeDeviceId, setActiveMediaDevice } =
useMediaDeviceSelect({
kind: 'videoinput',
requestPermissions: true,
})
// getCapabilities type is not available.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const getDeviceFacingMode = (device: any): string[] => {
if (!device.getCapabilities) {
return []
}
const capabilities = device.getCapabilities()
if (!capabilities) {
return []
}
if (typeof capabilities.facingMode !== 'object') {
return []
}
return capabilities.facingMode
}
const detectCurrentFacingMode = (): FacingMode | null => {
if (!activeDeviceId) {
return null
}
const activeDevice = devices.find(
(device) => device.deviceId === activeDeviceId
)
if (!activeDevice) {
return null
}
const facingMode = getDeviceFacingMode(activeDevice)
if (facingMode.indexOf(FacingMode.USER) >= 0) {
return FacingMode.USER
}
if (facingMode.indexOf(FacingMode.ENVIRONMENT) >= 0) {
return FacingMode.ENVIRONMENT
}
return null
}
const guessCurrentFacingMode = () => {
const facingMode = detectCurrentFacingMode()
if (facingMode) {
return facingMode
}
// We consider by default if we have no clue that the user camera is used.
return FacingMode.USER
}
const [facingMode, setFacingMode] = useState<FacingMode | null>()
/**
* Before setting the initial value of facingMode we need to wait for devices to
* be loaded ( because in detectCurrentFacingMode we need to find the active device
* in the devices list ), which is not the case at first render.
*/
useEffect(() => {
if (devices.length == 0) {
return
}
if (facingMode) {
return
}
setFacingMode(guessCurrentFacingMode())
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [devices])
const getUserDevice = (
facingMode: FacingMode
): MediaDeviceInfo | undefined => {
return devices.find((device) => {
return getDeviceFacingMode(device).indexOf(facingMode) >= 0
})
}
const toggle = () => {
let target: FacingMode
if (facingMode === FacingMode.USER) {
target = FacingMode.ENVIRONMENT
} else {
target = FacingMode.USER
}
const device = getUserDevice(target)
if (device) {
setActiveMediaDevice(device.deviceId)
setFacingMode(target)
} else {
console.error('Cannot get user device with facingMode ' + target)
}
}
return (
<Button
onPress={(e) => {
toggle()
props.onPress?.(e)
}}
variant="primaryTextDark"
aria-label={t('options.items.switchCamera')}
tooltip={t('options.items.switchCamera')}
description={true}
>
<RiCameraSwitchLine size={20} />
</Button>
)
}
@@ -8,7 +8,7 @@ export const OptionsButton = () => {
return (
<>
<Menu>
<Menu variant="dark">
<Button
square
variant="primaryDark"
@@ -26,7 +26,7 @@ export const OptionsMenuItems = () => {
<MenuSection>
<MenuItem
onAction={() => toggleEffects()}
className={menuRecipe({ icon: true }).item}
className={menuRecipe({ icon: true, variant: 'dark' }).item}
>
<RiAccountBoxLine size={20} />
{t('effects')}
@@ -37,13 +37,13 @@ export const OptionsMenuItems = () => {
<MenuItem
href={GRIST_FORM}
target="_blank"
className={menuRecipe({ icon: true }).item}
className={menuRecipe({ icon: true, variant: 'dark' }).item}
>
<RiMegaphoneLine size={20} />
{t('feedback')}
</MenuItem>
<MenuItem
className={menuRecipe({ icon: true }).item}
className={menuRecipe({ icon: true, variant: 'dark' }).item}
onAction={() => setDialogOpen(true)}
>
<RiSettings3Line size={20} />
@@ -46,9 +46,9 @@ export const ScreenShareToggle = ({
{...props}
>
<Div position="relative">
<RiRectangleLine size={28} />
<RiRectangleLine size={24} />
<Icon
size={16}
size={14}
style={{
position: 'absolute',
top: '50%',
@@ -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,12 +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
}
export const SelectToggleDevice = <T extends ToggleSource>({
track,
initialDeviceId,
onActiveDeviceChange,
hideMenu,
variant = 'primaryDark',
menuVariant = 'light',
...props
}: SelectToggleDeviceProps<T>) => {
const config = selectToggleDeviceConfig[props.source]
@@ -81,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}` })
@@ -92,29 +116,43 @@ export const SelectToggleDevice = <T extends ToggleSource>({
gap: '1px',
})}
>
<ToggleDevice {...trackProps} config={config} />
<Menu>
<Button
tooltip={selectLabel}
aria-label={selectLabel}
groupPosition="right"
square
variant={trackProps.enabled ? 'primaryDark' : 'error2'}
>
<RiArrowDownSLine />
</Button>
<MenuList
items={devices.map((d) => ({
value: d.deviceId,
label: d.label,
}))}
selectedItem={activeDeviceId}
onAction={(value) => {
setActiveMediaDevice(value as string)
onActiveDeviceChange(value as string)
}}
/>
</Menu>
<ToggleDevice
{...trackProps}
config={config}
variant={variant}
toggleButtonProps={{
...(hideMenu
? {
groupPosition: undefined,
}
: {}),
}}
/>
{!hideMenu && (
<Menu variant={menuVariant}>
<Button
tooltip={selectLabel}
aria-label={selectLabel}
groupPosition="right"
square
variant={trackProps.enabled ? variant : 'error2'}
>
<RiArrowDownSLine />
</Button>
<MenuList
items={devices.map((d) => ({
value: d.deviceId,
label: d.label,
}))}
selectedItem={activeDeviceId}
onAction={(value) => {
setActiveMediaDevice(value as string)
onActiveDeviceChange(value as string)
}}
variant={menuVariant}
/>
</Menu>
)}
</div>
)
}
@@ -6,18 +6,28 @@ 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>
}
export const ToggleDevice = ({
config,
enabled,
toggle,
variant = 'primaryDark',
toggleButtonProps,
}: ToggleDeviceProps) => {
const { t } = useTranslation('rooms', { keyPrefix: 'join' })
@@ -48,24 +58,29 @@ 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}
tooltip={toggleLabel}
groupPosition="left"
{...toggleButtonProps}
>
<Icon />
</ToggleButton>
)
}
const ActiveSpeakerWrapper = () => {
const { localParticipant } = useLocalParticipant()
const isSpeaking = useIsSpeaking(localParticipant)
return <ActiveSpeaker isSpeaking={isSpeaking} pushToTalk />
}
@@ -62,6 +62,7 @@ export function DesktopControlBar({
onActiveDeviceChange={(deviceId) =>
saveAudioInputDeviceId(deviceId ?? '')
}
menuVariant="dark"
/>
<SelectToggleDevice
source={Track.Source.Camera}
@@ -72,6 +73,7 @@ export function DesktopControlBar({
onActiveDeviceChange={(deviceId) =>
saveVideoInputDeviceId(deviceId ?? '')
}
menuVariant="dark"
/>
{browserSupportsScreenSharing && (
<ScreenShareToggle
@@ -23,6 +23,7 @@ import { LinkButton } from '@/primitives'
import { useSettingsDialog } from '../../components/controls/SettingsDialogContext'
import { ResponsiveMenu } from './ResponsiveMenu'
import { TranscriptToggle } from '../../components/controls/TranscriptToggle'
import { CameraSwitchButton } from '../../components/controls/CameraSwitchButton'
export function MobileControlBar({
onDeviceError,
@@ -55,7 +56,7 @@ export function MobileControlBar({
className={css({
display: 'flex',
justifyContent: 'space-between',
width: '422px',
width: '330px',
})}
>
<LeaveButton />
@@ -68,6 +69,7 @@ export function MobileControlBar({
onActiveDeviceChange={(deviceId) =>
saveAudioInputDeviceId(deviceId ?? '')
}
hideMenu={true}
/>
<SelectToggleDevice
source={Track.Source.Camera}
@@ -78,6 +80,7 @@ export function MobileControlBar({
onActiveDeviceChange={(deviceId) =>
saveVideoInputDeviceId(deviceId ?? '')
}
hideMenu={true}
/>
<HandToggle />
<Button
@@ -174,6 +177,7 @@ export function MobileControlBar({
>
<RiSettings3Line size={20} />
</Button>
<CameraSwitchButton onPress={() => setIsMenuOpened(false)} />
</div>
</div>
</ResponsiveMenu>
+6 -2
View File
@@ -24,7 +24,10 @@
"toggleOff": "",
"toggleOn": "",
"usernameHint": "",
"usernameLabel": ""
"usernameLabel": "",
"errors": {
"usernameEmpty": ""
}
},
"leaveRoomPrompt": "",
"shareDialog": {
@@ -83,7 +86,8 @@
"feedback": "",
"settings": "",
"username": "",
"effects": ""
"effects": "",
"switchCamera": ""
}
},
"effects": {
+7 -3
View File
@@ -18,13 +18,16 @@
"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"
}
},
"leaveRoomPrompt": "This will make you leave the meeting.",
"shareDialog": {
@@ -82,7 +85,8 @@
"feedback": "Give us feedback",
"settings": "Settings",
"username": "Update Your Name",
"effects": "Apply effects"
"effects": "Apply effects",
"switchCamera": "Switch camera"
}
},
"effects": {
+7 -3
View File
@@ -18,13 +18,16 @@
"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"
}
},
"leaveRoomPrompt": "Revenir à l'accueil vous fera quitter la réunion.",
"shareDialog": {
@@ -82,7 +85,8 @@
"feedback": "Partager votre avis",
"settings": "Paramètres",
"username": "Choisir votre nom",
"effects": "Appliquer des effets"
"effects": "Appliquer des effets",
"switchCamera": "Changer de caméra"
}
},
"effects": {
+6 -2
View File
@@ -30,7 +30,7 @@ const box = cva({
},
},
variant: {
default: {
light: {
borderWidth: '1px',
borderStyle: 'solid',
borderColor: 'box.border',
@@ -46,6 +46,10 @@ const box = cva({
backgroundColor: 'box.bg',
color: 'control.text',
},
dark: {
backgroundColor: 'primaryDark.50',
borderColord: 'primaryDark.50',
},
},
size: {
default: {
@@ -57,7 +61,7 @@ const box = cva({
},
},
defaultVariants: {
variant: 'default',
variant: 'light',
size: 'default',
},
})
+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>)}
+3 -1
View File
@@ -8,15 +8,17 @@ import { Box } from './Box'
*/
export const Menu = ({
children,
variant = 'light',
}: {
children: [trigger: ReactNode, menu: ReactNode]
variant?: 'dark' | 'light'
}) => {
const [trigger, menu] = children
return (
<MenuTrigger>
{trigger}
<StyledPopover>
<Box size="sm" type="popover">
<Box size="sm" type="popover" variant={variant}>
{menu}
</Box>
</StyledPopover>
+6 -1
View File
@@ -10,6 +10,7 @@ export const MenuList = <T extends string | number = string>({
onAction,
selectedItem,
items = [],
variant = 'light',
...menuProps
}: {
onAction: (key: T) => void
@@ -18,7 +19,11 @@ export const MenuList = <T extends string | number = string>({
} & MenuProps<unknown> &
RecipeVariantProps<typeof menuRecipe>) => {
const [variantProps] = menuRecipe.splitVariantProps(menuProps)
const classes = menuRecipe({ extraPadding: true, ...variantProps })
const classes = menuRecipe({
extraPadding: true,
variant: variant,
...variantProps,
})
return (
<Menu
selectionMode={selectedItem !== undefined ? 'single' : undefined}
+1 -1
View File
@@ -4,7 +4,7 @@ import { Separator as RACSeparator } from 'react-aria-components'
export const Separator = styled(RACSeparator, {
base: {
height: '1px',
background: 'gray.400',
background: 'primaryDark.200',
margin: '4px 0',
},
})
+6 -2
View File
@@ -46,7 +46,11 @@ export const menuRecipe = sva({
},
},
},
dark: {},
dark: {
item: {
color: 'white',
},
},
},
extraPadding: {
true: {
@@ -67,6 +71,6 @@ export const menuRecipe = sva({
},
},
defaultVariants: {
variant: 'dark',
variant: 'light',
},
})
+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.11.0"
"node": "22.13.0"
},
"repository": "https://github.com/numerique-gouv/meet",
"author": "DINUM",
+4 -4
View File
@@ -9,15 +9,15 @@ dependencies = [
"pydantic-settings>=2.1.0",
"celery==5.4.0",
"redis==5.2.1",
"minio==7.2.14",
"openai==1.59.7",
"minio==7.2.15",
"openai==1.59.8",
"requests==2.32.3",
"sentry-sdk[fastapi, celery]==2.19.2",
"sentry-sdk[fastapi, celery]==2.20.0",
]
[project.optional-dependencies]
dev = [
"ruff==0.9.1",
"ruff==0.9.2",
]
[build-system]