📱(front) add responsive menu on mobile

We want to have a specific responsive menu on mobile browsers.
It also implied to refactor a bit the way the settings modals is opened
because it could be opened from this responsive menu, so in order to achive
that a specific context has been created in order to allow its opening
from any sub component of the control bar.
This commit is contained in:
Nathan Vasse
2024-12-13 15:23:44 +01:00
committed by NathanVss
parent f1959cbb3a
commit c6fdeaf1e9
11 changed files with 511 additions and 189 deletions

View File

@@ -1,16 +1,11 @@
import { useTranslation } from 'react-i18next'
import { RiMore2Line } from '@remixicon/react'
import { Button, Menu } from '@/primitives'
import { useState } from 'react'
import { OptionsMenuItems } from '@/features/rooms/livekit/components/controls/Options/OptionsMenuItems'
import { SettingsDialogExtended } from '@/features/settings/components/SettingsDialogExtended'
export type DialogState = 'username' | 'settings' | null
export const OptionsButton = () => {
const { t } = useTranslation('rooms')
const [dialogOpen, setDialogOpen] = useState<DialogState>(null)
return (
<>
<Menu>
@@ -22,12 +17,8 @@ export const OptionsButton = () => {
>
<RiMore2Line />
</Button>
<OptionsMenuItems onOpenDialog={setDialogOpen} />
<OptionsMenuItems />
</Menu>
<SettingsDialogExtended
isOpen={dialogOpen === 'settings'}
onOpenChange={(v) => !v && setDialogOpen(null)}
/>
</>
)
}

View File

@@ -5,20 +5,16 @@ import {
} from '@remixicon/react'
import { MenuItem, Menu as RACMenu, Section } from 'react-aria-components'
import { useTranslation } from 'react-i18next'
import { Dispatch, SetStateAction } from 'react'
import { DialogState } from './OptionsButton'
import { Separator } from '@/primitives/Separator'
import { useSidePanel } from '../../../hooks/useSidePanel'
import { menuRecipe } from '@/primitives/menuRecipe.ts'
import { useSettingsDialog } from '../SettingsDialogContext'
// @todo try refactoring it to use MenuList component
export const OptionsMenuItems = ({
onOpenDialog,
}: {
onOpenDialog: Dispatch<SetStateAction<DialogState>>
}) => {
export const OptionsMenuItems = () => {
const { t } = useTranslation('rooms', { keyPrefix: 'options.items' })
const { toggleEffects } = useSidePanel()
const { setDialogOpen } = useSettingsDialog()
return (
<RACMenu
style={{
@@ -47,7 +43,7 @@ export const OptionsMenuItems = ({
</MenuItem>
<MenuItem
className={menuRecipe({ icon: true }).item}
onAction={() => onOpenDialog('settings')}
onAction={() => setDialogOpen(true)}
>
<RiSettings3Line size={20} />
{t('settings')}

View File

@@ -0,0 +1,36 @@
import { SettingsDialogExtended } from '@/features/settings/components/SettingsDialogExtended'
import React, { createContext, useContext, useState } from 'react'
const SettingsDialogContext = createContext<
| {
dialogOpen: boolean
setDialogOpen: React.Dispatch<React.SetStateAction<boolean>>
}
| undefined
>(undefined)
export const SettingsDialogProvider: React.FC<{
children: React.ReactNode
}> = ({ children }) => {
const [dialogOpen, setDialogOpen] = useState(false)
return (
<SettingsDialogContext.Provider value={{ dialogOpen, setDialogOpen }}>
{children}
<SettingsDialogExtended
isOpen={dialogOpen}
onOpenChange={(v) => !v && setDialogOpen(false)}
/>
</SettingsDialogContext.Provider>
)
}
// eslint-disable-next-line react-refresh/only-export-components
export const useSettingsDialog = () => {
const context = useContext(SettingsDialogContext)
if (!context) {
throw new Error(
'useSettingsDialog must be used within a SettingsDialogProvider'
)
}
return context
}

View File

@@ -4,8 +4,13 @@ import { useTranslation } from 'react-i18next'
import { useSidePanel } from '../../hooks/useSidePanel'
import { useHasTranscriptAccess } from '../../hooks/useHasTranscriptAccess'
import { css } from '@/styled-system/css'
import { ToggleButtonProps } from '@/primitives/ToggleButton'
export const TranscriptToggle = () => {
export const TranscriptToggle = ({
variant = 'primaryDark',
onPress,
...props
}: ToggleButtonProps) => {
const { t } = useTranslation('rooms', { keyPrefix: 'controls.transcript' })
const { isTranscriptOpen, toggleTranscript } = useSidePanel()
@@ -24,11 +29,15 @@ export const TranscriptToggle = () => {
>
<ToggleButton
square
variant="primaryTextDark"
variant={variant}
aria-label={t(tooltipLabel)}
tooltip={t(tooltipLabel)}
isSelected={isTranscriptOpen}
onPress={() => toggleTranscript()}
onPress={(e) => {
toggleTranscript()
onPress?.(e)
}}
{...props}
>
<RiBardLine />
</ToggleButton>

View File

@@ -1,166 +0,0 @@
import { Track } from 'livekit-client'
import * as React from 'react'
import { supportsScreenSharing } from '@livekit/components-core'
import { usePersistentUserChoices } from '@livekit/components-react'
import { StartMediaButton } from '../components/controls/StartMediaButton'
import { OptionsButton } from '../components/controls/Options/OptionsButton'
import { ParticipantsToggle } from '../components/controls/Participants/ParticipantsToggle'
import { ChatToggle } from '../components/controls/ChatToggle'
import { HandToggle } from '../components/controls/HandToggle'
import { SelectToggleDevice } from '../components/controls/SelectToggleDevice'
import { LeaveButton } from '../components/controls/LeaveButton'
import { ScreenShareToggle } from '../components/controls/ScreenShareToggle'
import { SupportToggle } from '../components/controls/SupportToggle'
import { TranscriptToggle } from '../components/controls/TranscriptToggle'
import { css } from '@/styled-system/css'
/** @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 enable 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({
saveUserChoices = true,
onDeviceError,
}: ControlBarProps) {
const browserSupportsScreenSharing = supportsScreenSharing()
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
className={css({
width: '100vw',
display: 'flex',
position: 'absolute',
padding: '1.125rem',
bottom: 0,
left: 0,
right: 0,
})}
>
<div
className={css({
display: 'flex',
justifyContent: 'flex-start',
flex: '1 1 33%',
alignItems: 'center',
gap: '0.5rem',
marginLeft: '0.5rem',
})}
></div>
<div
className={css({
flex: '1 1 33%',
alignItems: 'center',
justifyContent: 'center',
display: 'flex',
gap: '0.65rem',
})}
>
<SelectToggleDevice
source={Track.Source.Microphone}
onChange={microphoneOnChange}
onDeviceError={(error) =>
onDeviceError?.({ source: Track.Source.Microphone, error })
}
onActiveDeviceChange={(deviceId) =>
saveAudioInputDeviceId(deviceId ?? '')
}
/>
<SelectToggleDevice
source={Track.Source.Camera}
onChange={cameraOnChange}
onDeviceError={(error) =>
onDeviceError?.({ source: Track.Source.Camera, error })
}
onActiveDeviceChange={(deviceId) =>
saveVideoInputDeviceId(deviceId ?? '')
}
/>
{browserSupportsScreenSharing && (
<ScreenShareToggle
onDeviceError={(error) =>
onDeviceError?.({ source: Track.Source.ScreenShare, error })
}
/>
)}
<HandToggle />
<OptionsButton />
<LeaveButton />
<StartMediaButton />
</div>
<div
className={css({
display: 'flex',
justifyContent: 'flex-end',
flex: '1 1 33%',
alignItems: 'center',
gap: '0.5rem',
paddingRight: '0.25rem',
})}
>
<ChatToggle />
<ParticipantsToggle />
<TranscriptToggle />
<SupportToggle />
</div>
</div>
)
}

View File

@@ -0,0 +1,103 @@
import { Track } from 'livekit-client'
import * as React from 'react'
import { usePersistentUserChoices } from '@livekit/components-react'
import { MobileControlBar } from './MobileControlBar'
import { DesktopControlBar } from './DesktopControlBar'
import { SettingsDialogProvider } from '../../components/controls/SettingsDialogContext'
import { useIsMobile } from '@/utils/useIsMobile'
/** @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 enable 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({
saveUserChoices = true,
onDeviceError,
}: ControlBarProps) {
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]
)
const barProps = {
onDeviceError,
microphoneOnChange,
cameraOnChange,
saveAudioInputDeviceId,
saveVideoInputDeviceId,
}
const isMobile = useIsMobile()
return (
<SettingsDialogProvider>
{isMobile ? (
<MobileControlBar {...barProps} />
) : (
<DesktopControlBar {...barProps} />
)}
</SettingsDialogProvider>
)
}
export interface ControlBarAuxProps {
onDeviceError: ControlBarProps['onDeviceError']
microphoneOnChange: (
enabled: boolean,
isUserInitiated: boolean
) => void | null
cameraOnChange: (enabled: boolean, isUserInitiated: boolean) => void | null
saveAudioInputDeviceId: (deviceId: string) => void
saveVideoInputDeviceId: (deviceId: string) => void
}

View File

@@ -0,0 +1,106 @@
import { supportsScreenSharing } from '@livekit/components-core'
import { ControlBarAuxProps } from './ControlBar'
import { css } from '@/styled-system/css'
import { LeaveButton } from '../../components/controls/LeaveButton'
import { SelectToggleDevice } from '../../components/controls/SelectToggleDevice'
import { Track } from 'livekit-client'
import { HandToggle } from '../../components/controls/HandToggle'
import { ScreenShareToggle } from '../../components/controls/ScreenShareToggle'
import { OptionsButton } from '../../components/controls/Options/OptionsButton'
import { StartMediaButton } from '../../components/controls/StartMediaButton'
import { ChatToggle } from '../../components/controls/ChatToggle'
import { ParticipantsToggle } from '../../components/controls/Participants/ParticipantsToggle'
import { SupportToggle } from '../../components/controls/SupportToggle'
import { TranscriptToggle } from '../../components/controls/TranscriptToggle'
export function DesktopControlBar({
onDeviceError,
microphoneOnChange,
cameraOnChange,
saveAudioInputDeviceId,
saveVideoInputDeviceId,
}: ControlBarAuxProps) {
const browserSupportsScreenSharing = supportsScreenSharing()
return (
<>
<div
className={css({
width: '100vw',
display: 'flex',
position: 'absolute',
padding: '1.125rem',
bottom: 0,
left: 0,
right: 0,
})}
>
<div
className={css({
display: 'flex',
justifyContent: 'flex-start',
flex: '1 1 33%',
alignItems: 'center',
gap: '0.5rem',
marginLeft: '0.5rem',
})}
></div>
<div
className={css({
flex: '1 1 33%',
alignItems: 'center',
justifyContent: 'center',
display: 'flex',
gap: '0.65rem',
})}
>
<SelectToggleDevice
source={Track.Source.Microphone}
onChange={microphoneOnChange}
onDeviceError={(error) =>
onDeviceError?.({ source: Track.Source.Microphone, error })
}
onActiveDeviceChange={(deviceId) =>
saveAudioInputDeviceId(deviceId ?? '')
}
/>
<SelectToggleDevice
source={Track.Source.Camera}
onChange={cameraOnChange}
onDeviceError={(error) =>
onDeviceError?.({ source: Track.Source.Camera, error })
}
onActiveDeviceChange={(deviceId) =>
saveVideoInputDeviceId(deviceId ?? '')
}
/>
{browserSupportsScreenSharing && (
<ScreenShareToggle
onDeviceError={(error) =>
onDeviceError?.({ source: Track.Source.ScreenShare, error })
}
/>
)}
<HandToggle />
<OptionsButton />
<LeaveButton />
<StartMediaButton />
</div>
<div
className={css({
display: 'flex',
justifyContent: 'flex-end',
flex: '1 1 33%',
alignItems: 'center',
gap: '0.5rem',
paddingRight: '0.25rem',
})}
>
<ChatToggle />
<ParticipantsToggle />
<TranscriptToggle />
<SupportToggle />
</div>
</div>
</>
)
}

View File

@@ -0,0 +1,182 @@
import { supportsScreenSharing } from '@livekit/components-core'
import { useTranslation } from 'react-i18next'
import { ControlBarAuxProps } from './ControlBar'
import React from 'react'
import { css } from '@/styled-system/css'
import { LeaveButton } from '../../components/controls/LeaveButton'
import { SelectToggleDevice } from '../../components/controls/SelectToggleDevice'
import { Track } from 'livekit-client'
import { HandToggle } from '../../components/controls/HandToggle'
import { Button } from '@/primitives/Button'
import {
RiAccountBoxLine,
RiMegaphoneLine,
RiMore2Line,
RiSettings3Line,
} from '@remixicon/react'
import { ScreenShareToggle } from '../../components/controls/ScreenShareToggle'
import { ChatToggle } from '../../components/controls/ChatToggle'
import { ParticipantsToggle } from '../../components/controls/Participants/ParticipantsToggle'
import { SupportToggle } from '../../components/controls/SupportToggle'
import { useSidePanel } from '../../hooks/useSidePanel'
import { LinkButton } from '@/primitives'
import { useSettingsDialog } from '../../components/controls/SettingsDialogContext'
import { ResponsiveMenu } from './ResponsiveMenu'
import { TranscriptToggle } from '../../components/controls/TranscriptToggle'
export function MobileControlBar({
onDeviceError,
microphoneOnChange,
cameraOnChange,
saveAudioInputDeviceId,
saveVideoInputDeviceId,
}: ControlBarAuxProps) {
const { t } = useTranslation('rooms')
const [isMenuOpened, setIsMenuOpened] = React.useState(false)
const browserSupportsScreenSharing = supportsScreenSharing()
const { toggleEffects } = useSidePanel()
const { setDialogOpen } = useSettingsDialog()
return (
<>
<div
className={css({
width: '100vw',
display: 'flex',
position: 'absolute',
padding: '1.125rem',
justifyContent: 'center',
bottom: 0,
left: 0,
right: 0,
})}
>
<div
className={css({
display: 'flex',
justifyContent: 'space-between',
width: '422px',
})}
>
<LeaveButton />
<SelectToggleDevice
source={Track.Source.Microphone}
onChange={microphoneOnChange}
onDeviceError={(error) =>
onDeviceError?.({ source: Track.Source.Microphone, error })
}
onActiveDeviceChange={(deviceId) =>
saveAudioInputDeviceId(deviceId ?? '')
}
/>
<SelectToggleDevice
source={Track.Source.Camera}
onChange={cameraOnChange}
onDeviceError={(error) =>
onDeviceError?.({ source: Track.Source.Camera, error })
}
onActiveDeviceChange={(deviceId) =>
saveVideoInputDeviceId(deviceId ?? '')
}
/>
<HandToggle />
<Button
square
variant="primaryDark"
aria-label={t('options.buttonLabel')}
tooltip={t('options.buttonLabel')}
onPress={() => setIsMenuOpened(true)}
>
<RiMore2Line />
</Button>
</div>
</div>
<ResponsiveMenu
isOpened={isMenuOpened}
onClosed={() => setIsMenuOpened(false)}
>
<div
className={css({
display: 'flex',
justifyContent: 'center',
})}
>
<div
className={css({
flexGrow: 1,
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(100px, 1fr))',
gridGap: '1rem',
'& > *': {
alignSelf: 'center',
justifySelf: 'center',
},
})}
>
{browserSupportsScreenSharing && (
<ScreenShareToggle
onDeviceError={(error) =>
onDeviceError?.({ source: Track.Source.ScreenShare, error })
}
variant="primaryTextDark"
description={true}
onPress={() => setIsMenuOpened(false)}
/>
)}
<ChatToggle
description={true}
onPress={() => setIsMenuOpened(false)}
/>
<ParticipantsToggle
description={true}
onPress={() => setIsMenuOpened(false)}
/>
<TranscriptToggle
description={true}
onPress={() => setIsMenuOpened(false)}
/>
<SupportToggle
description={true}
onPress={() => setIsMenuOpened(false)}
/>
<Button
onPress={() => {
toggleEffects()
setIsMenuOpened(false)
}}
variant="primaryTextDark"
aria-label={t('options.items.effects')}
tooltip={t('options.items.effects')}
description={true}
>
<RiAccountBoxLine size={20} />
</Button>
<LinkButton
href="https://grist.incubateur.net/o/docs/forms/1YrfNP1QSSy8p2gCxMFnSf/4"
variant="primaryTextDark"
tooltip={t('options.items.feedbacks')}
aria-label={t('options.items.feedbacks')}
description={true}
target="_blank"
onPress={() => setIsMenuOpened(false)}
>
<RiMegaphoneLine size={20} />
</LinkButton>
<Button
onPress={() => {
setDialogOpen(true)
setIsMenuOpened(false)
}}
variant="primaryTextDark"
aria-label={t('options.items.settings')}
tooltip={t('options.items.settings')}
description={true}
>
<RiSettings3Line size={20} />
</Button>
</div>
</div>
</ResponsiveMenu>
</>
)
}

View File

@@ -0,0 +1,56 @@
import { css } from '@/styled-system/css'
import { PropsWithChildren } from 'react'
import { Dialog, Modal, ModalOverlay } from 'react-aria-components'
interface ResponsiveMenuProps extends PropsWithChildren {
isOpened: boolean
onClosed: () => void
}
export function ResponsiveMenu({
isOpened,
onClosed,
children,
}: ResponsiveMenuProps) {
return (
<ModalOverlay
isDismissable
isOpen={isOpened}
onOpenChange={(isOpened) => {
if (!isOpened) {
onClosed()
}
}}
className={css({
width: '100vw',
height: 'var(--visual-viewport-height)',
zIndex: 100,
justifyContent: 'center',
alignItems: 'flex-end',
display: 'flex',
position: 'fixed',
top: 0,
left: 0,
padding: '1.5rem 1.5rem 1rem 1.5rem',
boxSizing: 'border-box',
})}
>
<Modal
className={css({
backgroundColor: 'primaryDark.200',
borderRadius: '20px',
flexGrow: 1,
padding: '1.5rem',
'&[data-entering]': {
animation: 'slide-full 200ms',
},
'&[data-exiting]': {
animation: 'slide-full 200ms reverse',
},
})}
>
<Dialog>{children}</Dialog>
</Modal>
</ModalOverlay>
)
}

View File

@@ -20,7 +20,7 @@ import {
useCreateLayoutContext,
} from '@livekit/components-react'
import { ControlBar } from './ControlBar'
import { ControlBar } from './ControlBar/ControlBar'
import { styled } from '@/styled-system/jsx'
import { cva } from '@/styled-system/css'
import { MainNotificationToast } from '@/features/notifications/MainNotificationToast'

View File

@@ -29,3 +29,12 @@ body,
body:has(.lk-video-conference) #crisp-chatbox > div > a {
display: none !important;
}
@keyframes slide-full {
from {
transform: translateY(100%);
}
to {
transform: translateY(0);
}
}