diff --git a/CHANGELOG.md b/CHANGELOG.md index 177c7aaa..da0062cd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,10 @@ and this project adheres to ## [Unreleased] +### Changed + +- 📱(frontend) collapse mobile control bar items on narrow viewports + ## [1.28.0] - 2026-08-24 ### Added diff --git a/src/frontend/src/features/layout/components/ControlBarRegion.tsx b/src/frontend/src/features/layout/components/ControlBarRegion.tsx index 4550e5c0..2f195507 100644 --- a/src/frontend/src/features/layout/components/ControlBarRegion.tsx +++ b/src/frontend/src/features/layout/components/ControlBarRegion.tsx @@ -13,7 +13,7 @@ const controlBarRegion = cva({ mobile: { true: { justifyContent: 'center', - width: '330px', + width: '100%', }, }, }, diff --git a/src/frontend/src/features/reactions/components/ReactionsToggle.tsx b/src/frontend/src/features/reactions/components/ReactionsToggle.tsx index 9bd20795..65bcaad2 100644 --- a/src/frontend/src/features/reactions/components/ReactionsToggle.tsx +++ b/src/frontend/src/features/reactions/components/ReactionsToggle.tsx @@ -1,4 +1,3 @@ -import { useCallback } from 'react' import { useTranslation } from 'react-i18next' import { RiEmotionLine } from '@remixicon/react' import { ToggleButton } from '@/primitives' @@ -7,6 +6,8 @@ import { useRegisterKeyboardShortcut } from '@/features/shortcuts/useRegisterKey import { REACTIONS_TOOLBAR_ID } from '../constants' import { useReactionsToolbar } from '../hooks/useReactionsToolbar' import { layoutStore } from '@/stores/layout' +import { type ButtonRecipeProps } from '@/primitives/buttonRecipe' +import { ToggleButtonProps } from '@/primitives/ToggleButton' const focusReactionsToolbar = () => { document @@ -17,35 +18,44 @@ const focusReactionsToolbar = () => { export const REACTIONS_TOGGLE_ID = 'reactions-toggle' -export const ReactionsToggle = () => { +/* eslint-disable react-refresh/only-export-components */ +export const reactionShortcutHandler = () => { + if (layoutStore.showReactionsToolbar) { + focusReactionsToolbar() + } else { + layoutStore.showReactionsToolbar = true + } +} + +type Props = Pick, 'variant'> & ToggleButtonProps + +export const ReactionsToggle = ({ + variant = 'primaryDark', + onPress, + ...props +}: Props) => { const { t } = useTranslation('rooms', { keyPrefix: 'controls.reactions' }) const { isOpen, toggle } = useReactionsToolbar() - const handleShortcut = useCallback(() => { - if (layoutStore.showReactionsToolbar) { - focusReactionsToolbar() - } else { - layoutStore.showReactionsToolbar = true - } - }, []) - useRegisterKeyboardShortcut({ id: 'reaction', - handler: handleShortcut, + handler: reactionShortcutHandler, }) return ( diff --git a/src/frontend/src/features/rooms/livekit/components/controls/HandToggle.tsx b/src/frontend/src/features/rooms/livekit/components/controls/HandToggle.tsx index d0640db5..1c23d9dd 100644 --- a/src/frontend/src/features/rooms/livekit/components/controls/HandToggle.tsx +++ b/src/frontend/src/features/rooms/livekit/components/controls/HandToggle.tsx @@ -10,10 +10,18 @@ import { showLowerHandToast, } from '@/features/notifications/utils' import { useRegisterKeyboardShortcut } from '@/features/shortcuts/useRegisterKeyboardShortcut' +import { type ButtonRecipeProps } from '@/primitives/buttonRecipe' +import { ToggleButtonProps } from '@/primitives/ToggleButton' const SPEAKING_DETECTION_DELAY = 3000 -export const HandToggle = () => { +type Props = Pick, 'variant'> & ToggleButtonProps + +export const HandToggle = ({ + variant = 'primaryDark', + onPress, + ...props +}: Props) => { const { t } = useTranslation('rooms', { keyPrefix: 'controls.hand' }) const room = useRoomContext() @@ -74,12 +82,16 @@ export const HandToggle = () => { })} > { + handleToggle() + onPress?.(e) + }} data-attr={`controls-hand-${tooltipLabel}`} > diff --git a/src/frontend/src/features/rooms/livekit/prefabs/ControlBar/MobileControlBar.tsx b/src/frontend/src/features/rooms/livekit/prefabs/ControlBar/MobileControlBar.tsx index a9221124..6eb4036b 100644 --- a/src/frontend/src/features/rooms/livekit/prefabs/ControlBar/MobileControlBar.tsx +++ b/src/frontend/src/features/rooms/livekit/prefabs/ControlBar/MobileControlBar.tsx @@ -1,7 +1,7 @@ import { supportsScreenSharing } from '@livekit/components-core' import { useTranslation } from 'react-i18next' import type { ControlBarAuxProps } from './ControlBar' -import React from 'react' +import React, { useLayoutEffect, useRef, useState } from 'react' import { css } from '@/styled-system/css' import { LeaveButton } from '../../components/controls/LeaveButton' import { Track } from 'livekit-client' @@ -26,7 +26,26 @@ import { AudioDevicesControl } from '../../components/controls/Device/AudioDevic import { VideoDeviceControl } from '../../components/controls/Device/VideoDeviceControl' import { openSettingsDialog } from '@/stores/settings' import { ControlBarRegion } from '@/features/layout/components/ControlBarRegion' -import { ReactionsToggle } from '@/features/reactions/components/ReactionsToggle' +import { + ReactionsToggle, + reactionShortcutHandler, +} from '@/features/reactions/components/ReactionsToggle' +import { useSize } from '../../hooks/useResizeObserver' +import { useRegisterKeyboardShortcut } from '@/features/shortcuts/useRegisterKeyboardShortcut' +import { useRaisedHand } from '@/features/rooms/livekit/hooks/useRaisedHand' +import { useRoomContext } from '@livekit/components-react' + +// Hand collapses first, then reactions; hidden toggles move into the menu. +const COLLAPSIBLE_COUNT = 2 + +// Layout-neutral measuring wrapper: inherits the region's gap and refuses to +// flex-shrink so measured widths are natural content widths. +const measuredRow = css({ + display: 'inline-flex', + alignItems: 'center', + gap: 'inherit', + flexShrink: 0, +}) export function MobileControlBar({ onDeviceError, @@ -36,50 +55,107 @@ export function MobileControlBar({ const browserSupportsScreenSharing = supportsScreenSharing() const { toggleEffects } = useSidePanel() + const containerRef = useRef(null) + const { width } = useSize(containerRef) + const barRef = useRef(null) + const { width: barWidth } = useSize(barRef) + const collapsibleRef = useRef(null) + const { width: collapsibleWidth } = useSize(collapsibleRef) + + const [hiddenCount, setHiddenCount] = useState(0) + const calibration = useRef<{ essential: number; slot: number }>() + + useLayoutEffect(() => { + if (hiddenCount === 0 && collapsibleWidth > 0 && barRef.current) { + const gap = parseFloat(getComputedStyle(barRef.current).columnGap) || 0 + calibration.current = { + essential: barWidth - collapsibleWidth - gap, + slot: (collapsibleWidth + gap) / COLLAPSIBLE_COUNT, + } + } + if (!calibration.current || width <= 0) return + const { essential, slot } = calibration.current + const fits = Math.floor((width - essential) / slot) + const next = Math.min( + COLLAPSIBLE_COUNT, + Math.max(0, COLLAPSIBLE_COUNT - fits) + ) + if (next !== hiddenCount) setHiddenCount(next) + }, [barWidth, collapsibleWidth, hiddenCount, width, setHiddenCount]) + + const hideHand = hiddenCount >= 1 + const hideReactions = hiddenCount >= 2 + + const room = useRoomContext() + const { toggleRaisedHand } = useRaisedHand({ + participant: room.localParticipant, + }) + useRegisterKeyboardShortcut({ + id: 'raise-hand', + handler: toggleRaisedHand, + }) + useRegisterKeyboardShortcut({ + id: 'reaction', + handler: reactionShortcutHandler, + }) + const { data } = useConfig() + const closeMenu = () => setIsMenuOpened(false) + return ( <>
- - - - onDeviceError?.({ source: Track.Source.Microphone, error }) - } - hideMenu={true} - /> - - onDeviceError?.({ source: Track.Source.Camera, error }) - } - hideMenu={true} - /> - - - - +
+ +
+ + + onDeviceError?.({ source: Track.Source.Microphone, error }) + } + hideMenu={true} + /> + + onDeviceError?.({ source: Track.Source.Camera, error }) + } + hideMenu={true} + /> + {/* Unmounted when empty so it doesn't leave a stray gap. */} + {!hideReactions && ( +
+ + {!hideHand && } +
+ )} + +
+
+
- setIsMenuOpened(false)} - > +
+ {hideReactions && ( + + )} + {hideHand && ( + + )} {browserSupportsScreenSharing && ( @@ -105,25 +195,16 @@ export function MobileControlBar({ } variant="primaryTextDark" description={true} - onPress={() => setIsMenuOpened(false)} + onPress={closeMenu} /> )} - setIsMenuOpened(false)} - /> - setIsMenuOpened(false)} - /> - setIsMenuOpened(false)} - /> + + + - setIsMenuOpened(false)} /> +