(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 { useEffect, useMemo } from 'react'
import { useSnapshot } from 'valtio'
import { permissionsStore } from '@/stores/permissions'
import { closePermissionsDialog, permissionsStore } from '@/stores/permissions'
import { useTranslation } from 'react-i18next'
import { injectIconIntoTranslation } from '@/utils/translation'
@@ -41,7 +41,7 @@ export const Permissions = () => {
permissions.isCameraGranted &&
permissions.isMicrophoneGranted
) {
permissionsStore.isPermissionDialogOpen = false
closePermissionsDialog()
}
}, [permissions])
@@ -56,7 +56,7 @@ export const Permissions = () => {
aria-label={t(`heading.${permissionLabel}`, {
appTitle,
})}
onClose={() => (permissionsStore.isPermissionDialogOpen = false)}
onClose={closePermissionsDialog}
>
<div
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 { css } from '@/styled-system/css'
import { ButtonRecipeProps } from '@/primitives/buttonRecipe'
import { useEffect } from 'react'
import { useEffect, useMemo } from 'react'
import { usePersistentUserChoices } from '../../hooks/usePersistentUserChoices'
import { BackgroundProcessorFactory } from '../blur'
import { useSnapshot } from 'valtio'
import { permissionsStore } from '@/stores/permissions'
export type ToggleSource = Exclude<
Track.Source,
@@ -101,6 +103,18 @@ export const SelectToggleDevice = <T extends ToggleSource>({
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 = () => {
if (props.source === Track.Source.Camera) {
/**
@@ -161,6 +175,7 @@ export const SelectToggleDevice = <T extends ToggleSource>({
config={config}
variant={variant}
toggle={toggle}
isPermissionDeniedOrPrompted={isPermissionDeniedOrPrompted}
toggleButtonProps={{
...(hideMenu
? {
@@ -172,11 +187,16 @@ export const SelectToggleDevice = <T extends ToggleSource>({
{!hideMenu && (
<Menu variant={menuVariant}>
<Button
isDisabled={isPermissionDeniedOrPrompted}
tooltip={selectLabel}
aria-label={selectLabel}
groupPosition="right"
square
variant={trackProps.enabled ? variant : 'error2'}
variant={
trackProps.enabled && !isPermissionDeniedOrPrompted
? variant
: 'error2'
}
>
<RiArrowDownSLine />
</Button>

View File

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

View File

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

View File

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

View File

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

View File

@@ -270,6 +270,20 @@ export const buttonRecipe = cva({
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: {
true: {

View File

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