♻️(frontend) extract permission checks into reusable hook by media kind

Create hook to encapsulate permission denied/prompted/loading checks
based on media kind, eliminating props drilling and simplifying code.

Returns appropriate permission state for consuming components based on
media type, cleaning up code structure with small enhancement.
This commit is contained in:
lebaudantoine
2025-08-21 21:56:17 +02:00
committed by aleb_the_flash
parent ebf676529f
commit 22c68da2af
7 changed files with 57 additions and 74 deletions

View File

@@ -33,12 +33,12 @@ import { ApiLobbyStatus, ApiRequestEntry } from '../api/requestEntry'
import { Spinner } from '@/primitives/Spinner' import { Spinner } from '@/primitives/Spinner'
import { ApiAccessLevel } from '../api/ApiRoom' import { ApiAccessLevel } from '../api/ApiRoom'
import { useLoginHint } from '@/hooks/useLoginHint' import { useLoginHint } from '@/hooks/useLoginHint'
import { useSnapshot } from 'valtio' import { openPermissionsDialog } from '@/stores/permissions'
import { openPermissionsDialog, permissionsStore } from '@/stores/permissions'
import { ToggleDevice } from './join/ToggleDevice' import { ToggleDevice } from './join/ToggleDevice'
import { useResolveInitiallyDefaultDeviceId } from '../livekit/hooks/useResolveInitiallyDefaultDeviceId' import { useResolveInitiallyDefaultDeviceId } from '../livekit/hooks/useResolveInitiallyDefaultDeviceId'
import { isSafari } from '@/utils/livekit' import { isSafari } from '@/utils/livekit'
import type { LocalUserChoices } from '@/stores/userChoices' import type { LocalUserChoices } from '@/stores/userChoices'
import { useCannotUseDevice } from '../livekit/hooks/useCannotUseDevice'
const onError = (e: Error) => console.error('ERROR', e) const onError = (e: Error) => console.error('ERROR', e)
@@ -350,13 +350,8 @@ export const Join = ({
enterRoom() enterRoom()
} }
const permissions = useSnapshot(permissionsStore) const isCameraDeniedOrPrompted = useCannotUseDevice('videoinput')
const isMicrophoneDeniedOrPrompted = useCannotUseDevice('audioinput')
const isCameraDeniedOrPrompted =
permissions.isCameraDenied || permissions.isCameraPrompted
const isMicrophoneDeniedOrPrompted =
permissions.isMicrophoneDenied || permissions.isMicrophonePrompted
const hintMessage = useMemo(() => { const hintMessage = useMemo(() => {
if (isCameraDeniedOrPrompted) { if (isCameraDeniedOrPrompted) {

View File

@@ -6,9 +6,7 @@ import {
} from '../../livekit/config/ToggleDeviceConfig' } from '../../livekit/config/ToggleDeviceConfig'
import { LocalAudioTrack, LocalVideoTrack } from 'livekit-client' import { LocalAudioTrack, LocalVideoTrack } from 'livekit-client'
import { ButtonRecipeProps } from '@/primitives/buttonRecipe' import { ButtonRecipeProps } from '@/primitives/buttonRecipe'
import { useCallback, useMemo, useState } from 'react' import { useCallback, useState } from 'react'
import { useSnapshot } from 'valtio'
import { permissionsStore } from '@/stores/permissions'
type ToggleDeviceProps<T extends ToggleSource> = UseTrackToggleProps<T> & { type ToggleDeviceProps<T extends ToggleSource> = UseTrackToggleProps<T> & {
track?: LocalAudioTrack | LocalVideoTrack track?: LocalAudioTrack | LocalVideoTrack
@@ -31,17 +29,6 @@ export const ToggleDevice = <T extends ToggleSource>({
props.initialState ?? false props.initialState ?? false
) )
const permissions = useSnapshot(permissionsStore)
const isPermissionDeniedOrPrompted = useMemo(() => {
if (config.kind == 'audioinput') {
return permissions.isMicrophoneDenied || permissions.isMicrophonePrompted
}
if (config.kind == 'videoinput') {
return permissions.isCameraDenied || permissions.isCameraPrompted
}
}, [config, permissions])
const toggle = useCallback(async () => { const toggle = useCallback(async () => {
try { try {
if (isTrackEnabled) { if (isTrackEnabled) {
@@ -61,7 +48,6 @@ export const ToggleDevice = <T extends ToggleSource>({
return ( return (
<BaseToggleDevice <BaseToggleDevice
enabled={isTrackEnabled} enabled={isTrackEnabled}
isPermissionDeniedOrPrompted={isPermissionDeniedOrPrompted}
toggle={toggle} toggle={toggle}
config={config} config={config}
variant="whiteCircle" variant="whiteCircle"

View File

@@ -7,8 +7,7 @@ import { Track } from 'livekit-client'
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 { usePersistentUserChoices } from '../../../hooks/usePersistentUserChoices' import { usePersistentUserChoices } from '../../../hooks/usePersistentUserChoices'
import { useSnapshot } from 'valtio' import { useCannotUseDevice } from '../../../hooks/useCannotUseDevice'
import { permissionsStore } from '@/stores/permissions'
import { ToggleDeviceConfig } from '../../../config/ToggleDeviceConfig' import { ToggleDeviceConfig } from '../../../config/ToggleDeviceConfig'
import Source = Track.Source import Source = Track.Source
import * as React from 'react' import * as React from 'react'
@@ -58,10 +57,7 @@ export const AudioDevicesControl = ({
...props, ...props,
}) })
const permissions = useSnapshot(permissionsStore) const cannotUseDevice = useCannotUseDevice('audioinput')
const isPermissionDeniedOrPrompted =
permissions.isMicrophoneDenied || permissions.isMicrophonePrompted
const selectLabel = t('audioinput.choose') const selectLabel = t('audioinput.choose')
return ( return (
@@ -76,7 +72,6 @@ export const AudioDevicesControl = ({
config={config} config={config}
variant="primaryDark" variant="primaryDark"
toggle={trackProps.toggle} toggle={trackProps.toggle}
isPermissionDeniedOrPrompted={isPermissionDeniedOrPrompted}
toggleButtonProps={{ toggleButtonProps={{
...(hideMenu ...(hideMenu
? { ? {
@@ -88,15 +83,12 @@ export const AudioDevicesControl = ({
{!hideMenu && ( {!hideMenu && (
<Popover variant="dark" withArrow={false}> <Popover variant="dark" withArrow={false}>
<Button <Button
isDisabled={isPermissionDeniedOrPrompted}
tooltip={selectLabel} tooltip={selectLabel}
aria-label={selectLabel} aria-label={selectLabel}
groupPosition="right" groupPosition="right"
square square
variant={ variant={
trackProps.enabled && !isPermissionDeniedOrPrompted trackProps.enabled && !cannotUseDevice ? 'primaryDark' : 'error2'
? 'primaryDark'
: 'error2'
} }
> >
<RiArrowUpSLine /> <RiArrowUpSLine />

View File

@@ -8,9 +8,8 @@ import { useTranslation } from 'react-i18next'
import { useMediaDeviceSelect } from '@livekit/components-react' import { useMediaDeviceSelect } from '@livekit/components-react'
import { useEffect, useMemo } from 'react' import { useEffect, useMemo } from 'react'
import { Select } from '@/primitives/Select' import { Select } from '@/primitives/Select'
import { useSnapshot } from 'valtio'
import { permissionsStore } from '@/stores/permissions'
import { Placement } from '@react-types/overlays' import { Placement } from '@react-types/overlays'
import { useCannotUseDevice } from '../../../hooks/useCannotUseDevice'
type DeviceItems = Array<{ value: string; label: string }> type DeviceItems = Array<{ value: string; label: string }>
@@ -100,8 +99,6 @@ export const SelectDevice = ({
}: SelectDeviceProps) => { }: SelectDeviceProps) => {
const { t } = useTranslation('rooms', { keyPrefix: 'join' }) const { t } = useTranslation('rooms', { keyPrefix: 'join' })
const permissions = useSnapshot(permissionsStore)
const contextProps = useMemo<SelectDeviceContext>(() => { const contextProps = useMemo<SelectDeviceContext>(() => {
if (context == 'room') { if (context == 'room') {
return { variant: 'dark', placement: 'top' } return { variant: 'dark', placement: 'top' }
@@ -126,22 +123,11 @@ export const SelectDevice = ({
} }
}, [kind]) }, [kind])
const isPermissionDeniedOrPrompted = useMemo(() => { const cannotUseDevice = useCannotUseDevice(kind)
if (kind == 'audioinput') {
return permissions.isMicrophoneDenied || permissions.isMicrophonePrompted
}
if (kind == 'videoinput') {
return permissions.isCameraDenied || permissions.isCameraPrompted
}
if (kind == 'audiooutput') {
return permissions.isMicrophoneDenied || permissions.isMicrophonePrompted
}
return false
}, [kind, permissions])
if (!config) return null if (!config) return null
if (isPermissionDeniedOrPrompted || permissions.isLoading) { if (cannotUseDevice) {
return ( return (
<Select <Select
aria-label={t(`${kind}.permissionsNeeded`)} aria-label={t(`${kind}.permissionsNeeded`)}

View File

@@ -7,9 +7,8 @@ import { Track, VideoCaptureOptions } from 'livekit-client'
import { ToggleDevice } from '@/features/rooms/livekit/components/controls/ToggleDevice' import { ToggleDevice } from '@/features/rooms/livekit/components/controls/ToggleDevice'
import { css } from '@/styled-system/css' import { css } from '@/styled-system/css'
import { usePersistentUserChoices } from '../../../hooks/usePersistentUserChoices' import { usePersistentUserChoices } from '../../../hooks/usePersistentUserChoices'
import { useCannotUseDevice } from '../../../hooks/useCannotUseDevice'
import { BackgroundProcessorFactory } from '../../blur' import { BackgroundProcessorFactory } from '../../blur'
import { useSnapshot } from 'valtio'
import { permissionsStore } from '@/stores/permissions'
import { ToggleDeviceConfig } from '../../../config/ToggleDeviceConfig' import { ToggleDeviceConfig } from '../../../config/ToggleDeviceConfig'
import Source = Track.Source import Source = Track.Source
import * as React from 'react' import * as React from 'react'
@@ -53,10 +52,7 @@ export const VideoDeviceControl = ({
...props, ...props,
}) })
const permissions = useSnapshot(permissionsStore) const cannotUseDevice = useCannotUseDevice('videoinput')
const isPermissionDeniedOrPrompted =
permissions.isCameraDenied || permissions.isCameraPrompted
const toggle = () => { const toggle = () => {
/** /**
@@ -99,7 +95,6 @@ export const VideoDeviceControl = ({
config={config} config={config}
variant="primaryDark" variant="primaryDark"
toggle={toggle} toggle={toggle}
isPermissionDeniedOrPrompted={isPermissionDeniedOrPrompted}
toggleButtonProps={{ toggleButtonProps={{
...(hideMenu ...(hideMenu
? { ? {
@@ -111,15 +106,12 @@ export const VideoDeviceControl = ({
{!hideMenu && ( {!hideMenu && (
<Popover variant="dark" withArrow={false}> <Popover variant="dark" withArrow={false}>
<Button <Button
isDisabled={isPermissionDeniedOrPrompted}
tooltip={selectLabel} tooltip={selectLabel}
aria-label={selectLabel} aria-label={selectLabel}
groupPosition="right" groupPosition="right"
square square
variant={ variant={
trackProps.enabled && !isPermissionDeniedOrPrompted trackProps.enabled && !cannotUseDevice ? 'primaryDark' : 'error2'
? 'primaryDark'
: 'error2'
} }
> >
<RiArrowUpSLine /> <RiArrowUpSLine />

View File

@@ -15,10 +15,10 @@ import { ButtonRecipeProps } from '@/primitives/buttonRecipe'
import { ToggleButtonProps } from '@/primitives/ToggleButton' import { ToggleButtonProps } from '@/primitives/ToggleButton'
import { openPermissionsDialog } from '@/stores/permissions' import { openPermissionsDialog } from '@/stores/permissions'
import { ToggleDeviceConfig } from '../../config/ToggleDeviceConfig' import { ToggleDeviceConfig } from '../../config/ToggleDeviceConfig'
import { useCannotUseDevice } from '../../hooks/useCannotUseDevice'
export type ToggleDeviceProps = { export type ToggleDeviceProps = {
enabled: boolean enabled: boolean
isPermissionDeniedOrPrompted?: boolean
toggle: () => void toggle: () => void
config: ToggleDeviceConfig config: ToggleDeviceConfig
variant?: NonNullable<ButtonRecipeProps>['variant'] variant?: NonNullable<ButtonRecipeProps>['variant']
@@ -33,7 +33,6 @@ export const ToggleDevice = ({
variant = 'primaryDark', variant = 'primaryDark',
errorVariant = 'error2', errorVariant = 'error2',
toggleButtonProps, toggleButtonProps,
isPermissionDeniedOrPrompted,
}: ToggleDeviceProps) => { }: ToggleDeviceProps) => {
const { t } = useTranslation('rooms', { keyPrefix: 'join' }) const { t } = useTranslation('rooms', { keyPrefix: 'join' })
@@ -52,6 +51,8 @@ export const ToggleDevice = ({
setPushToTalk(false) setPushToTalk(false)
} }
const cannotUseDevice = useCannotUseDevice(kind)
useRegisterKeyboardShortcut({ shortcut, handler: toggle }) useRegisterKeyboardShortcut({ shortcut, handler: toggle })
useLongPress({ keyCode: longPress?.key, onKeyDown, onKeyUp }) useLongPress({ keyCode: longPress?.key, onKeyDown, onKeyUp })
@@ -62,7 +63,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 && !isPermissionDeniedOrPrompted ? iconOn : iconOff const Icon = enabled && !cannotUseDevice ? iconOn : iconOff
const context = useMaybeRoomContext() const context = useMaybeRoomContext()
if (kind === 'audioinput' && pushToTalk && context) { if (kind === 'audioinput' && pushToTalk && context) {
@@ -71,19 +72,15 @@ export const ToggleDevice = ({
return ( return (
<div style={{ position: 'relative' }}> <div style={{ position: 'relative' }}>
{isPermissionDeniedOrPrompted && <PermissionNeededButton />} {cannotUseDevice && <PermissionNeededButton />}
<ToggleButton <ToggleButton
isSelected={!enabled} isSelected={!enabled}
variant={ variant={enabled && !cannotUseDevice ? variant : errorVariant}
enabled && !isPermissionDeniedOrPrompted ? variant : errorVariant
}
shySelected shySelected
onPress={() => onPress={() => (cannotUseDevice ? openPermissionsDialog() : toggle())}
isPermissionDeniedOrPrompted ? openPermissionsDialog() : toggle()
}
aria-label={toggleLabel} aria-label={toggleLabel}
tooltip={ tooltip={
isPermissionDeniedOrPrompted cannotUseDevice
? t('tooltip', { keyPrefix: 'permissionsButton' }) ? t('tooltip', { keyPrefix: 'permissionsButton' })
: toggleLabel : toggleLabel
} }

View File

@@ -0,0 +1,35 @@
import { useSnapshot } from 'valtio'
import { useMemo } from 'react'
import { permissionsStore } from '@/stores/permissions'
export const useCannotUseDevice = (kind: MediaDeviceKind) => {
const {
isLoading,
isMicrophoneDenied,
isMicrophonePrompted,
isCameraDenied,
isCameraPrompted,
} = useSnapshot(permissionsStore)
return useMemo(() => {
if (isLoading) return true
switch (kind) {
case 'audioinput':
case 'audiooutput': // audiooutput uses microphone permissions
return isMicrophoneDenied || isMicrophonePrompted
case 'videoinput':
return isCameraDenied || isCameraPrompted
default:
return false
}
}, [
kind,
isLoading,
isMicrophoneDenied,
isMicrophonePrompted,
isCameraDenied,
isCameraPrompted,
])
}