✨(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:
committed by
aleb_the_flash
parent
120bcdc720
commit
4fae3c6c47
@@ -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({
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -94,6 +94,10 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"permissionsButton": {
|
||||||
|
"tooltip": "Mehr Infos",
|
||||||
|
"ariaLabel": "Berechtigungsproblem. Mehr Infos anzeigen"
|
||||||
|
},
|
||||||
"error": {
|
"error": {
|
||||||
"createRoom": {
|
"createRoom": {
|
||||||
"heading": "Authentifizierung erforderlich",
|
"heading": "Authentifizierung erforderlich",
|
||||||
|
|||||||
@@ -94,6 +94,10 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"permissionsButton": {
|
||||||
|
"tooltip": "More info",
|
||||||
|
"ariaLabel": "Permissions issue. Show more info"
|
||||||
|
},
|
||||||
"error": {
|
"error": {
|
||||||
"createRoom": {
|
"createRoom": {
|
||||||
"heading": "Authentication Required",
|
"heading": "Authentication Required",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user