(frontend) add visual permission indicator to device toggle button

Introduce accessible visual indicator on device toggle buttons to hint
when users have permission issues that require action.

Provides clear visual warning to help users understand they need to
resolve permissions before using camera/microphone features. Follows
accessibility guidelines for proper user guidance.
This commit is contained in:
lebaudantoine
2025-08-09 17:36:45 +02:00
committed by aleb_the_flash
parent 120bcdc720
commit 4fae3c6c47
10 changed files with 154 additions and 21 deletions

View File

@@ -4,7 +4,7 @@ import { Dialog, H } from '@/primitives'
import { RiEqualizer2Line } from '@remixicon/react' import { RiEqualizer2Line } from '@remixicon/react'
import { useEffect, useMemo } from 'react' import { useEffect, useMemo } from 'react'
import { useSnapshot } from 'valtio' import { useSnapshot } from 'valtio'
import { permissionsStore } from '@/stores/permissions' import { closePermissionsDialog, permissionsStore } from '@/stores/permissions'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { injectIconIntoTranslation } from '@/utils/translation' import { injectIconIntoTranslation } from '@/utils/translation'
@@ -41,7 +41,7 @@ export const Permissions = () => {
permissions.isCameraGranted && permissions.isCameraGranted &&
permissions.isMicrophoneGranted permissions.isMicrophoneGranted
) { ) {
permissionsStore.isPermissionDialogOpen = false closePermissionsDialog()
} }
}, [permissions]) }, [permissions])
@@ -56,7 +56,7 @@ export const Permissions = () => {
aria-label={t(`heading.${permissionLabel}`, { aria-label={t(`heading.${permissionLabel}`, {
appTitle, appTitle,
})} })}
onClose={() => (permissionsStore.isPermissionDialogOpen = false)} onClose={closePermissionsDialog}
> >
<div <div
className={css({ className={css({

View File

@@ -0,0 +1,47 @@
import { Button } from '@/primitives'
import { RiErrorWarningFill } from '@remixicon/react'
import { openPermissionsDialog } from '@/stores/permissions'
import { css } from '@/styled-system/css'
import { useTranslation } from 'react-i18next'
export const PermissionNeededButton = () => {
const { t } = useTranslation('rooms', { keyPrefix: 'permissionsButton' })
return (
<div
className={css({
position: 'absolute',
bottom: 'auto',
left: '-.55rem',
top: '-.55rem',
zIndex: 1,
})}
>
<Button
aria-label={t('ariaLabel')}
tooltip={t('tooltip')}
onPress={openPermissionsDialog}
variant="permission"
>
<div
className={css({
position: 'relative',
zIndex: 2,
})}
>
<RiErrorWarningFill size={28} />
</div>
<div
className={css({
width: '18px',
height: '18px',
position: 'absolute',
top: '4px',
left: '4px',
backgroundColor: 'black',
borderRadius: '100%',
})}
/>
</Button>
</div>
)
}

View File

@@ -25,9 +25,11 @@ import { Shortcut } from '@/features/shortcuts/types'
import { ToggleDevice } from '@/features/rooms/livekit/components/controls/ToggleDevice.tsx' import { ToggleDevice } from '@/features/rooms/livekit/components/controls/ToggleDevice.tsx'
import { css } from '@/styled-system/css' import { css } from '@/styled-system/css'
import { ButtonRecipeProps } from '@/primitives/buttonRecipe' import { ButtonRecipeProps } from '@/primitives/buttonRecipe'
import { useEffect } from 'react' import { useEffect, useMemo } from 'react'
import { usePersistentUserChoices } from '../../hooks/usePersistentUserChoices' import { usePersistentUserChoices } from '../../hooks/usePersistentUserChoices'
import { BackgroundProcessorFactory } from '../blur' import { BackgroundProcessorFactory } from '../blur'
import { useSnapshot } from 'valtio'
import { permissionsStore } from '@/stores/permissions'
export type ToggleSource = Exclude< export type ToggleSource = Exclude<
Track.Source, Track.Source,
@@ -101,6 +103,18 @@ export const SelectToggleDevice = <T extends ToggleSource>({
const { userChoices } = usePersistentUserChoices() const { userChoices } = usePersistentUserChoices()
const permissions = useSnapshot(permissionsStore)
const isPermissionDeniedOrPrompted = useMemo(() => {
switch (config.kind) {
case 'audioinput':
return (
permissions.isMicrophoneDenied || permissions.isMicrophonePrompted
)
case 'videoinput':
return permissions.isCameraDenied || permissions.isCameraPrompted
}
}, [permissions, config.kind])
const toggle = () => { const toggle = () => {
if (props.source === Track.Source.Camera) { if (props.source === Track.Source.Camera) {
/** /**
@@ -161,6 +175,7 @@ export const SelectToggleDevice = <T extends ToggleSource>({
config={config} config={config}
variant={variant} variant={variant}
toggle={toggle} toggle={toggle}
isPermissionDeniedOrPrompted={isPermissionDeniedOrPrompted}
toggleButtonProps={{ toggleButtonProps={{
...(hideMenu ...(hideMenu
? { ? {
@@ -172,11 +187,16 @@ export const SelectToggleDevice = <T extends ToggleSource>({
{!hideMenu && ( {!hideMenu && (
<Menu variant={menuVariant}> <Menu variant={menuVariant}>
<Button <Button
isDisabled={isPermissionDeniedOrPrompted}
tooltip={selectLabel} tooltip={selectLabel}
aria-label={selectLabel} aria-label={selectLabel}
groupPosition="right" groupPosition="right"
square square
variant={trackProps.enabled ? variant : 'error2'} variant={
trackProps.enabled && !isPermissionDeniedOrPrompted
? variant
: 'error2'
}
> >
<RiArrowDownSLine /> <RiArrowDownSLine />
</Button> </Button>

View File

@@ -4,6 +4,7 @@ import { useMemo, useState } from 'react'
import { appendShortcutLabel } from '@/features/shortcuts/utils' import { appendShortcutLabel } from '@/features/shortcuts/utils'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { SelectToggleDeviceConfig } from './SelectToggleDevice' import { SelectToggleDeviceConfig } from './SelectToggleDevice'
import { PermissionNeededButton } from './PermissionNeededButton'
import useLongPress from '@/features/shortcuts/useLongPress' import useLongPress from '@/features/shortcuts/useLongPress'
import { ActiveSpeaker } from '@/features/rooms/components/ActiveSpeaker' import { ActiveSpeaker } from '@/features/rooms/components/ActiveSpeaker'
import { import {
@@ -13,9 +14,11 @@ import {
} from '@livekit/components-react' } from '@livekit/components-react'
import { ButtonRecipeProps } from '@/primitives/buttonRecipe' import { ButtonRecipeProps } from '@/primitives/buttonRecipe'
import { ToggleButtonProps } from '@/primitives/ToggleButton' import { ToggleButtonProps } from '@/primitives/ToggleButton'
import { openPermissionsDialog } from '@/stores/permissions'
export type ToggleDeviceProps = { export type ToggleDeviceProps = {
enabled: boolean enabled: boolean
isPermissionDeniedOrPrompted?: boolean
toggle: () => void toggle: () => void
config: SelectToggleDeviceConfig config: SelectToggleDeviceConfig
variant?: NonNullable<ButtonRecipeProps>['variant'] variant?: NonNullable<ButtonRecipeProps>['variant']
@@ -28,6 +31,7 @@ export const ToggleDevice = ({
toggle, toggle,
variant = 'primaryDark', variant = 'primaryDark',
toggleButtonProps, toggleButtonProps,
isPermissionDeniedOrPrompted,
}: ToggleDeviceProps) => { }: ToggleDeviceProps) => {
const { t } = useTranslation('rooms', { keyPrefix: 'join' }) const { t } = useTranslation('rooms', { keyPrefix: 'join' })
@@ -56,7 +60,7 @@ export const ToggleDevice = ({
return shortcut ? appendShortcutLabel(label, shortcut) : label return shortcut ? appendShortcutLabel(label, shortcut) : label
}, [enabled, kind, shortcut, t]) }, [enabled, kind, shortcut, t])
const Icon = enabled ? iconOn : iconOff const Icon = enabled && !isPermissionDeniedOrPrompted ? iconOn : iconOff
const context = useMaybeRoomContext() const context = useMaybeRoomContext()
if (kind === 'audioinput' && pushToTalk && context) { if (kind === 'audioinput' && pushToTalk && context) {
@@ -64,18 +68,27 @@ export const ToggleDevice = ({
} }
return ( return (
<ToggleButton <div style={{ position: 'relative' }}>
isSelected={!enabled} {isPermissionDeniedOrPrompted && <PermissionNeededButton />}
variant={enabled ? variant : 'error2'} <ToggleButton
shySelected isSelected={!enabled}
onPress={() => toggle()} variant={enabled && !isPermissionDeniedOrPrompted ? variant : 'error2'}
aria-label={toggleLabel} shySelected
tooltip={toggleLabel} onPress={() =>
groupPosition="left" isPermissionDeniedOrPrompted ? openPermissionsDialog() : toggle()
{...toggleButtonProps} }
> aria-label={toggleLabel}
<Icon /> tooltip={
</ToggleButton> isPermissionDeniedOrPrompted
? t('tooltip', { keyPrefix: 'permissionsButton' })
: toggleLabel
}
groupPosition="left"
{...toggleButtonProps}
>
<Icon />
</ToggleButton>
</div>
) )
} }

View File

@@ -94,6 +94,10 @@
} }
} }
}, },
"permissionsButton": {
"tooltip": "Mehr Infos",
"ariaLabel": "Berechtigungsproblem. Mehr Infos anzeigen"
},
"error": { "error": {
"createRoom": { "createRoom": {
"heading": "Authentifizierung erforderlich", "heading": "Authentifizierung erforderlich",

View File

@@ -94,6 +94,10 @@
} }
} }
}, },
"permissionsButton": {
"tooltip": "More info",
"ariaLabel": "Permissions issue. Show more info"
},
"error": { "error": {
"createRoom": { "createRoom": {
"heading": "Authentication Required", "heading": "Authentication Required",

View File

@@ -94,6 +94,10 @@
} }
} }
}, },
"permissionsButton": {
"tooltip": "Plus d'infos",
"ariaLabel": "Problème de permissions. Afficher plus d'infos"
},
"error": { "error": {
"createRoom": { "createRoom": {
"heading": "Authentification requise", "heading": "Authentification requise",

View File

@@ -94,6 +94,10 @@
} }
} }
}, },
"permissionsButton": {
"tooltip": "Meer info",
"ariaLabel": "Probleem met machtigingen. Meer info weergeven"
},
"error": { "error": {
"createRoom": { "createRoom": {
"heading": "Verificatie vereist", "heading": "Verificatie vereist",

View File

@@ -270,6 +270,20 @@ export const buttonRecipe = cva({
color: 'primary !important', color: 'primary !important',
}, },
}, },
permission: {
position: 'relative',
// background: 'None !important',
borderRadius: '100%',
// border: 'none !important',
color: 'amber.500',
width: 'fit-content',
height: 'fit-content',
padding: '0 !important',
margin: '0 !important',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
},
}, },
invisible: { invisible: {
true: { true: {

View File

@@ -8,19 +8,30 @@ type PermissionState =
| 'denied' | 'denied'
| 'unavailable' | 'unavailable'
type State = { type BaseState = {
cameraPermission: PermissionState cameraPermission: PermissionState
microphonePermission: PermissionState microphonePermission: PermissionState
isLoading: boolean isLoading: boolean
isPermissionDialogOpen: boolean isPermissionDialogOpen: boolean
} }
export const permissionsStore = proxy<State>({ type DerivedState = {
isCameraGranted: boolean
isMicrophoneGranted: boolean
isCameraDenied: boolean
isMicrophoneDenied: boolean
isCameraPrompted: boolean
isMicrophonePrompted: boolean
}
type State = BaseState & DerivedState
export const permissionsStore = proxy<BaseState>({
cameraPermission: undefined, cameraPermission: undefined,
microphonePermission: undefined, microphonePermission: undefined,
isLoading: true, isLoading: true,
isPermissionDialogOpen: false, isPermissionDialogOpen: false,
}) }) as State
derive( derive(
{ {
@@ -31,8 +42,20 @@ derive(
isCameraDenied: (get) => get(permissionsStore).cameraPermission == 'denied', isCameraDenied: (get) => get(permissionsStore).cameraPermission == 'denied',
isMicrophoneDenied: (get) => isMicrophoneDenied: (get) =>
get(permissionsStore).microphonePermission == 'denied', get(permissionsStore).microphonePermission == 'denied',
isCameraPrompted: (get) =>
get(permissionsStore).cameraPermission == 'prompt',
isMicrophonePrompted: (get) =>
get(permissionsStore).microphonePermission == 'prompt',
}, },
{ {
proxy: permissionsStore, proxy: permissionsStore,
} }
) )
export const openPermissionsDialog = () => {
permissionsStore.isPermissionDialogOpen = true
}
export const closePermissionsDialog = () => {
permissionsStore.isPermissionDialogOpen = false
}