From 4fae3c6c473c825c8169fb1755251ffa59182043 Mon Sep 17 00:00:00 2001 From: lebaudantoine Date: Sat, 9 Aug 2025 17:36:45 +0200 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8(frontend)=20add=20visual=20permission?= =?UTF-8?q?=20indicator=20to=20device=20toggle=20button?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../features/rooms/components/Permissions.tsx | 6 +-- .../controls/PermissionNeededButton.tsx | 47 +++++++++++++++++++ .../controls/SelectToggleDevice.tsx | 24 +++++++++- .../components/controls/ToggleDevice.tsx | 39 ++++++++++----- src/frontend/src/locales/de/rooms.json | 4 ++ src/frontend/src/locales/en/rooms.json | 4 ++ src/frontend/src/locales/fr/rooms.json | 4 ++ src/frontend/src/locales/nl/rooms.json | 4 ++ src/frontend/src/primitives/buttonRecipe.ts | 14 ++++++ src/frontend/src/stores/permissions.ts | 29 ++++++++++-- 10 files changed, 154 insertions(+), 21 deletions(-) create mode 100644 src/frontend/src/features/rooms/livekit/components/controls/PermissionNeededButton.tsx diff --git a/src/frontend/src/features/rooms/components/Permissions.tsx b/src/frontend/src/features/rooms/components/Permissions.tsx index 63b9bb9b..a6433596 100644 --- a/src/frontend/src/features/rooms/components/Permissions.tsx +++ b/src/frontend/src/features/rooms/components/Permissions.tsx @@ -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} >
{ + const { t } = useTranslation('rooms', { keyPrefix: 'permissionsButton' }) + return ( +
+ +
+ ) +} diff --git a/src/frontend/src/features/rooms/livekit/components/controls/SelectToggleDevice.tsx b/src/frontend/src/features/rooms/livekit/components/controls/SelectToggleDevice.tsx index 240f36e6..8158d0cf 100644 --- a/src/frontend/src/features/rooms/livekit/components/controls/SelectToggleDevice.tsx +++ b/src/frontend/src/features/rooms/livekit/components/controls/SelectToggleDevice.tsx @@ -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 = ({ 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 = ({ config={config} variant={variant} toggle={toggle} + isPermissionDeniedOrPrompted={isPermissionDeniedOrPrompted} toggleButtonProps={{ ...(hideMenu ? { @@ -172,11 +187,16 @@ export const SelectToggleDevice = ({ {!hideMenu && ( diff --git a/src/frontend/src/features/rooms/livekit/components/controls/ToggleDevice.tsx b/src/frontend/src/features/rooms/livekit/components/controls/ToggleDevice.tsx index 411e88f7..cfe42590 100644 --- a/src/frontend/src/features/rooms/livekit/components/controls/ToggleDevice.tsx +++ b/src/frontend/src/features/rooms/livekit/components/controls/ToggleDevice.tsx @@ -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['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 ( - toggle()} - aria-label={toggleLabel} - tooltip={toggleLabel} - groupPosition="left" - {...toggleButtonProps} - > - - +
+ {isPermissionDeniedOrPrompted && } + + isPermissionDeniedOrPrompted ? openPermissionsDialog() : toggle() + } + aria-label={toggleLabel} + tooltip={ + isPermissionDeniedOrPrompted + ? t('tooltip', { keyPrefix: 'permissionsButton' }) + : toggleLabel + } + groupPosition="left" + {...toggleButtonProps} + > + + +
) } diff --git a/src/frontend/src/locales/de/rooms.json b/src/frontend/src/locales/de/rooms.json index 638c74e5..0bd34242 100644 --- a/src/frontend/src/locales/de/rooms.json +++ b/src/frontend/src/locales/de/rooms.json @@ -94,6 +94,10 @@ } } }, + "permissionsButton": { + "tooltip": "Mehr Infos", + "ariaLabel": "Berechtigungsproblem. Mehr Infos anzeigen" + }, "error": { "createRoom": { "heading": "Authentifizierung erforderlich", diff --git a/src/frontend/src/locales/en/rooms.json b/src/frontend/src/locales/en/rooms.json index eb80028f..7ceff7a9 100644 --- a/src/frontend/src/locales/en/rooms.json +++ b/src/frontend/src/locales/en/rooms.json @@ -94,6 +94,10 @@ } } }, + "permissionsButton": { + "tooltip": "More info", + "ariaLabel": "Permissions issue. Show more info" + }, "error": { "createRoom": { "heading": "Authentication Required", diff --git a/src/frontend/src/locales/fr/rooms.json b/src/frontend/src/locales/fr/rooms.json index 74b65dde..69b9004a 100644 --- a/src/frontend/src/locales/fr/rooms.json +++ b/src/frontend/src/locales/fr/rooms.json @@ -94,6 +94,10 @@ } } }, + "permissionsButton": { + "tooltip": "Plus d'infos", + "ariaLabel": "Problème de permissions. Afficher plus d'infos" + }, "error": { "createRoom": { "heading": "Authentification requise", diff --git a/src/frontend/src/locales/nl/rooms.json b/src/frontend/src/locales/nl/rooms.json index ef65b2fa..b7fe6b67 100644 --- a/src/frontend/src/locales/nl/rooms.json +++ b/src/frontend/src/locales/nl/rooms.json @@ -94,6 +94,10 @@ } } }, + "permissionsButton": { + "tooltip": "Meer info", + "ariaLabel": "Probleem met machtigingen. Meer info weergeven" + }, "error": { "createRoom": { "heading": "Verificatie vereist", diff --git a/src/frontend/src/primitives/buttonRecipe.ts b/src/frontend/src/primitives/buttonRecipe.ts index 5d6e2fa4..59b14a2e 100644 --- a/src/frontend/src/primitives/buttonRecipe.ts +++ b/src/frontend/src/primitives/buttonRecipe.ts @@ -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: { diff --git a/src/frontend/src/stores/permissions.ts b/src/frontend/src/stores/permissions.ts index 32641c11..1d8091c1 100644 --- a/src/frontend/src/stores/permissions.ts +++ b/src/frontend/src/stores/permissions.ts @@ -8,19 +8,30 @@ type PermissionState = | 'denied' | 'unavailable' -type State = { +type BaseState = { cameraPermission: PermissionState microphonePermission: PermissionState isLoading: boolean isPermissionDialogOpen: boolean } -export const permissionsStore = proxy({ +type DerivedState = { + isCameraGranted: boolean + isMicrophoneGranted: boolean + isCameraDenied: boolean + isMicrophoneDenied: boolean + isCameraPrompted: boolean + isMicrophonePrompted: boolean +} + +type State = BaseState & DerivedState + +export const permissionsStore = proxy({ 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 +}