🚸(frontend) disable device controls when user lacks room permissions

Update user experience by clearly marking device toggle and control
components as disabled when users have insufficient room permissions.

Prevents confusion by providing visual feedback that device controls are
unavailable, improving clarity about what  actions users can perform
in their current role.
This commit is contained in:
lebaudantoine
2025-08-28 15:47:06 +02:00
committed by aleb_the_flash
parent 7d1f15ef91
commit 3d3242e148
7 changed files with 54 additions and 7 deletions

View File

@@ -124,6 +124,7 @@ const config: Config = {
...pandaPreset.theme.tokens.colors, ...pandaPreset.theme.tokens.colors,
primaryDark: { primaryDark: {
50: { value: '#161622' }, 50: { value: '#161622' },
75: { value: '#222234' },
100: { value: '#2D2D46' }, 100: { value: '#2D2D46' },
200: { value: '#43436A' }, 200: { value: '#43436A' },
300: { value: '#5A5A8F' }, 300: { value: '#5A5A8F' },

View File

@@ -7,12 +7,14 @@ import { Track } from 'livekit-client'
import { ToggleDevice } from './ToggleDevice' import { ToggleDevice } from './ToggleDevice'
import { css } from '@/styled-system/css' import { css } from '@/styled-system/css'
import { usePersistentUserChoices } from '../../../hooks/usePersistentUserChoices' import { usePersistentUserChoices } from '../../../hooks/usePersistentUserChoices'
import { useCanPublishTrack } from '../../../hooks/useCanPublishTrack'
import { useCannotUseDevice } from '../../../hooks/useCannotUseDevice' import { useCannotUseDevice } from '../../../hooks/useCannotUseDevice'
import Source = Track.Source
import * as React from 'react' import * as React from 'react'
import { SelectDevice } from './SelectDevice' import { SelectDevice } from './SelectDevice'
import { SettingsButton } from './SettingsButton' import { SettingsButton } from './SettingsButton'
import { SettingsDialogExtendedKey } from '@/features/settings/type' import { SettingsDialogExtendedKey } from '@/features/settings/type'
import { TrackSource } from '@livekit/protocol'
import Source = Track.Source
type AudioDevicesControlProps = Omit< type AudioDevicesControlProps = Omit<
UseTrackToggleProps<Source.Microphone>, UseTrackToggleProps<Source.Microphone>,
@@ -50,6 +52,8 @@ export const AudioDevicesControl = ({
const cannotUseDevice = useCannotUseDevice(kind) const cannotUseDevice = useCannotUseDevice(kind)
const selectLabel = t(`settings.${SettingsDialogExtendedKey.AUDIO}`) const selectLabel = t(`settings.${SettingsDialogExtendedKey.AUDIO}`)
const canPublishTrack = useCanPublishTrack(TrackSource.MICROPHONE)
return ( return (
<div <div
className={css({ className={css({
@@ -59,6 +63,7 @@ export const AudioDevicesControl = ({
> >
<ToggleDevice <ToggleDevice
{...trackProps} {...trackProps}
isDisabled={!canPublishTrack}
kind={kind} kind={kind}
toggle={trackProps.toggle as () => Promise<void>} toggle={trackProps.toggle as () => Promise<void>}
overrideToggleButtonProps={{ overrideToggleButtonProps={{
@@ -77,7 +82,9 @@ export const AudioDevicesControl = ({
groupPosition="right" groupPosition="right"
square square
variant={ variant={
trackProps.enabled && !cannotUseDevice ? 'primaryDark' : 'error2' !canPublishTrack || !trackProps.enabled || cannotUseDevice
? 'error2'
: 'primaryDark'
} }
> >
<RiArrowUpSLine /> <RiArrowUpSLine />

View File

@@ -27,6 +27,7 @@ type ToggleDeviceStyleProps = {
export type ToggleDeviceProps<T extends ToggleSource> = { export type ToggleDeviceProps<T extends ToggleSource> = {
enabled: boolean enabled: boolean
isDisabled?: boolean
toggle: ( toggle: (
forceState?: boolean, forceState?: boolean,
captureOptions?: CaptureOptionsBySource<T> captureOptions?: CaptureOptionsBySource<T>
@@ -39,6 +40,7 @@ export type ToggleDeviceProps<T extends ToggleSource> = {
export const ToggleDevice = <T extends ToggleSource>({ export const ToggleDevice = <T extends ToggleSource>({
kind, kind,
enabled, enabled,
isDisabled,
toggle, toggle,
context = 'room', context = 'room',
overrideToggleButtonProps, overrideToggleButtonProps,
@@ -105,7 +107,9 @@ export const ToggleDevice = <T extends ToggleSource>({
}, [enabled, kind, deviceShortcut, t]) }, [enabled, kind, deviceShortcut, t])
const Icon = const Icon =
enabled && !cannotUseDevice ? deviceIcons.toggleOn : deviceIcons.toggleOff isDisabled || cannotUseDevice || !enabled
? deviceIcons.toggleOff
: deviceIcons.toggleOn
const roomContext = useMaybeRoomContext() const roomContext = useMaybeRoomContext()
if (kind === 'audioinput' && pushToTalk && roomContext) { if (kind === 'audioinput' && pushToTalk && roomContext) {
@@ -117,7 +121,10 @@ export const ToggleDevice = <T extends ToggleSource>({
{cannotUseDevice && <PermissionNeededButton />} {cannotUseDevice && <PermissionNeededButton />}
<ToggleButton <ToggleButton
isSelected={!enabled} isSelected={!enabled}
variant={enabled && !cannotUseDevice ? variant : errorVariant} isDisabled={isDisabled}
variant={
isDisabled || cannotUseDevice || !enabled ? errorVariant : variant
}
shySelected shySelected
onPress={() => (cannotUseDevice ? openPermissionsDialog() : toggle())} onPress={() => (cannotUseDevice ? openPermissionsDialog() : toggle())}
aria-label={toggleLabel} aria-label={toggleLabel}

View File

@@ -7,6 +7,7 @@ import { Track, VideoCaptureOptions } from 'livekit-client'
import { ToggleDevice } from './ToggleDevice' import { ToggleDevice } from './ToggleDevice'
import { css } from '@/styled-system/css' import { css } from '@/styled-system/css'
import { usePersistentUserChoices } from '../../../hooks/usePersistentUserChoices' import { usePersistentUserChoices } from '../../../hooks/usePersistentUserChoices'
import { useCanPublishTrack } from '../../../hooks/useCanPublishTrack'
import { useCannotUseDevice } from '../../../hooks/useCannotUseDevice' import { useCannotUseDevice } from '../../../hooks/useCannotUseDevice'
import { useSidePanel } from '../../../hooks/useSidePanel' import { useSidePanel } from '../../../hooks/useSidePanel'
import { BackgroundProcessorFactory } from '../../blur' import { BackgroundProcessorFactory } from '../../blur'
@@ -15,6 +16,7 @@ import * as React from 'react'
import { SelectDevice } from './SelectDevice' import { SelectDevice } from './SelectDevice'
import { SettingsButton } from './SettingsButton' import { SettingsButton } from './SettingsButton'
import { SettingsDialogExtendedKey } from '@/features/settings/type' import { SettingsDialogExtendedKey } from '@/features/settings/type'
import { TrackSource } from '@livekit/protocol'
const EffectsButton = ({ onPress }: { onPress: () => void }) => { const EffectsButton = ({ onPress }: { onPress: () => void }) => {
const { t } = useTranslation('rooms', { keyPrefix: 'selectDevice' }) const { t } = useTranslation('rooms', { keyPrefix: 'selectDevice' })
@@ -95,6 +97,7 @@ export const VideoDeviceControl = ({
} }
const selectLabel = t(`settings.${SettingsDialogExtendedKey.VIDEO}`) const selectLabel = t(`settings.${SettingsDialogExtendedKey.VIDEO}`)
const canPublishTrack = useCanPublishTrack(TrackSource.CAMERA)
return ( return (
<div <div
@@ -105,6 +108,7 @@ export const VideoDeviceControl = ({
> >
<ToggleDevice <ToggleDevice
{...trackProps} {...trackProps}
isDisabled={!canPublishTrack}
kind={kind} kind={kind}
toggle={toggleWithProcessor} toggle={toggleWithProcessor}
overrideToggleButtonProps={{ overrideToggleButtonProps={{
@@ -123,7 +127,9 @@ export const VideoDeviceControl = ({
groupPosition="right" groupPosition="right"
square square
variant={ variant={
trackProps.enabled && !cannotUseDevice ? 'primaryDark' : 'error2' !canPublishTrack || !trackProps.enabled || cannotUseDevice
? 'error2'
: 'primaryDark'
} }
> >
<RiArrowUpSLine /> <RiArrowUpSLine />

View File

@@ -6,6 +6,8 @@ import { Track } from 'livekit-client'
import React from 'react' import React from 'react'
import { type ButtonRecipeProps } from '@/primitives/buttonRecipe' import { type ButtonRecipeProps } from '@/primitives/buttonRecipe'
import { ToggleButtonProps } from '@/primitives/ToggleButton' import { ToggleButtonProps } from '@/primitives/ToggleButton'
import { TrackSource } from '@livekit/protocol'
import { useCanPublishTrack } from '@/features/rooms/livekit/hooks/useCanPublishTrack'
type Props = Omit< type Props = Omit<
UseTrackToggleProps<Track.Source.ScreenShare>, UseTrackToggleProps<Track.Source.ScreenShare>,
@@ -29,10 +31,13 @@ export const ScreenShareToggle = ({
const tooltipLabel = enabled ? 'stop' : 'start' const tooltipLabel = enabled ? 'stop' : 'start'
const Icon = enabled ? RiCloseFill : RiArrowUpLine const Icon = enabled ? RiCloseFill : RiArrowUpLine
const canShareScreen = useCanPublishTrack(TrackSource.SCREEN_SHARE)
// fixme - remove ToggleButton custom styles when we design a proper icon // fixme - remove ToggleButton custom styles when we design a proper icon
return ( return (
<ToggleButton <ToggleButton
isSelected={enabled} isSelected={enabled}
isDisabled={!canShareScreen}
square square
variant={variant} variant={variant}
tooltip={t(tooltipLabel)} tooltip={t(tooltipLabel)}

View File

@@ -0,0 +1,17 @@
import { TrackSource } from '@livekit/protocol'
import {
useLocalParticipant,
useParticipantPermissions,
} from '@livekit/components-react'
export function useCanPublishTrack(trackSource: TrackSource): boolean {
const { localParticipant } = useLocalParticipant()
const permissions = useParticipantPermissions({
participant: localParticipant,
})
return Boolean(
permissions?.canPublish &&
permissions?.canPublishSources?.includes(trackSource)
)
}

View File

@@ -148,6 +148,10 @@ export const buttonRecipe = cva({
backgroundColor: 'primaryDark.900', backgroundColor: 'primaryDark.900',
color: 'primaryDark.100', color: 'primaryDark.100',
}, },
'&[data-disabled]': {
backgroundColor: 'primaryDark.75',
color: 'primaryDark.300',
},
'&[data-hovered]': { '&[data-hovered]': {
backgroundColor: 'primaryDark.300', backgroundColor: 'primaryDark.300',
color: 'white', color: 'white',
@@ -247,8 +251,8 @@ export const buttonRecipe = cva({
color: 'error.100 !important', color: 'error.100 !important',
}, },
'&[data-disabled]': { '&[data-disabled]': {
backgroundColor: 'error.200', backgroundColor: 'error.200 !important',
color: 'error.300', color: 'error.300 !important',
}, },
}, },
errorCircle: { errorCircle: {