Skip to content

Commit b7db571

Browse files
committed
temp: added error message
1 parent 0a00f39 commit b7db571

File tree

4 files changed

+243
-20
lines changed

4 files changed

+243
-20
lines changed
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
// Copyright (C) 2024 Nethesis S.r.l.
2+
// SPDX-License-Identifier: AGPL-3.0-or-later
3+
4+
import React, { Component, ReactNode } from 'react'
5+
import { t } from 'i18next'
6+
import { Button } from '@renderer/components/Nethesis'
7+
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
8+
import { faExclamationTriangle } from '@fortawesome/free-solid-svg-icons'
9+
import { Log } from '@shared/utils/logger'
10+
11+
interface Props {
12+
children: ReactNode
13+
fallbackTitle?: string
14+
fallbackMessage?: string
15+
showDetails?: boolean
16+
}
17+
18+
interface State {
19+
hasError: boolean
20+
error?: Error
21+
errorInfo?: React.ErrorInfo
22+
}
23+
24+
export class ErrorBoundary extends Component<Props, State> {
25+
constructor(props: Props) {
26+
super(props)
27+
this.state = { hasError: false }
28+
}
29+
30+
static getDerivedStateFromError(error: Error): State {
31+
return { hasError: true, error }
32+
}
33+
34+
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
35+
Log.error('ErrorBoundary caught an error:', error, errorInfo)
36+
this.setState({
37+
error,
38+
errorInfo,
39+
})
40+
}
41+
42+
handleRetry = () => {
43+
this.setState({ hasError: false, error: undefined, errorInfo: undefined })
44+
}
45+
46+
render() {
47+
if (this.state.hasError) {
48+
const {
49+
fallbackTitle = t('Common.Error'),
50+
fallbackMessage = t('Common.Something went wrong'),
51+
showDetails = false,
52+
} = this.props
53+
54+
return (
55+
<div className='flex flex-col items-center justify-center p-6 bg-bgLight dark:bg-bgDark text-bgDark dark:text-bgLight rounded-lg shadow-lg max-w-md mx-auto'>
56+
<FontAwesomeIcon
57+
icon={faExclamationTriangle}
58+
className='text-red-500 text-4xl mb-4'
59+
/>
60+
<h2 className='text-xl font-semibold mb-2 text-center'>{fallbackTitle}</h2>
61+
<p className='text-gray-600 dark:text-gray-400 mb-4 text-center'>
62+
{fallbackMessage}
63+
</p>
64+
65+
{showDetails && this.state.error && (
66+
<details className='mb-4 w-full'>
67+
<summary className='cursor-pointer text-sm text-gray-500 hover:text-gray-700 dark:hover:text-gray-300'>
68+
{t('Common.Show details')}
69+
</summary>
70+
<div className='mt-2 p-2 bg-gray-100 dark:bg-gray-800 rounded text-xs font-mono overflow-auto max-h-32'>
71+
<div className='text-red-600 dark:text-red-400 font-semibold'>
72+
{this.state.error.name}: {this.state.error.message}
73+
</div>
74+
{this.state.error.stack && (
75+
<pre className='mt-1 text-gray-600 dark:text-gray-400 whitespace-pre-wrap'>
76+
{this.state.error.stack}
77+
</pre>
78+
)}
79+
</div>
80+
</details>
81+
)}
82+
83+
<Button
84+
variant='primary'
85+
onClick={this.handleRetry}
86+
className='px-4 py-2'
87+
>
88+
{t('Common.Try again')}
89+
</Button>
90+
</div>
91+
)
92+
}
93+
94+
return this.props.children
95+
}
96+
}

src/renderer/src/components/Modules/NethVoice/BaseModule/Navbar.tsx

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { ProfileDialog } from './ProfileDialog'
1111
import { PresenceForwardDialog } from './ProfileDialog/PresenceSettings/PresenceForwardDialog'
1212
import { SettingsShortcutDialog } from './ProfileDialog/SettingsSettings/SettingsShortcutDialog'
1313
import { SettingsDeviceDialog } from './ProfileDialog/SettingsSettings/SettingsDevicesDialog'
14+
import { ErrorBoundary } from '@renderer/components/ErrorBoundary'
1415

1516
export interface NavbarProps {
1617
onClickAccount: () => void
@@ -55,7 +56,15 @@ export function Navbar({ onClickAccount }: NavbarProps): JSX.Element {
5556
</div>
5657
{isForwardDialogOpen && <PresenceForwardDialog />}
5758
{isShortcutDialogOpen && <SettingsShortcutDialog />}
58-
{isDeviceDialogOpen && <SettingsDeviceDialog />}
59+
{isDeviceDialogOpen && (
60+
<ErrorBoundary
61+
fallbackTitle="Device Settings Error"
62+
fallbackMessage="Unable to load device settings. Please try again."
63+
showDetails={true}
64+
>
65+
<SettingsDeviceDialog />
66+
</ErrorBoundary>
67+
)}
5968
<ProfileDialog
6069
isOpen={isProfileDialogOpen}
6170
onClose={() => setIsProfileDialogOpen(false)}

src/renderer/src/components/Modules/NethVoice/BaseModule/ProfileDialog/SettingsSettings/SettingsBox.tsx

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
} from '@fortawesome/free-solid-svg-icons'
66
import { useNethlinkData } from '@renderer/store'
77
import { OptionElement } from '../OptionElement'
8+
import { ErrorBoundary } from '@renderer/components/ErrorBoundary'
89

910
export function SettingsBox({ onClose }: { onClose?: () => void }) {
1011
const [, setIsShortcutDialogOpen] = useNethlinkData('isShortcutDialogOpen')
@@ -21,15 +22,20 @@ export function SettingsBox({ onClose }: { onClose?: () => void }) {
2122
if (onClose) onClose()
2223
}}
2324
/>
24-
<OptionElement
25-
isSelected={false}
26-
icon={DevicesIcon}
27-
label={t('Settings.Devices')}
28-
onClick={() => {
29-
setIsDeviceDialogOpen(true)
30-
if (onClose) onClose()
31-
}}
32-
/>
25+
<ErrorBoundary
26+
fallbackTitle={t('Settings.Device settings error') || 'Device settings error'}
27+
fallbackMessage={t('Settings.Unable to open device settings') || 'Unable to open device settings'}
28+
>
29+
<OptionElement
30+
isSelected={false}
31+
icon={DevicesIcon}
32+
label={t('Settings.Devices')}
33+
onClick={() => {
34+
setIsDeviceDialogOpen(true)
35+
if (onClose) onClose()
36+
}}
37+
/>
38+
</ErrorBoundary>
3339
</div>
3440
)
3541
}

src/renderer/src/components/Modules/NethVoice/BaseModule/ProfileDialog/SettingsSettings/SettingsDevicesDialog.tsx

Lines changed: 122 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,13 @@ import { delay, isDev } from '@shared/utils/utils'
2525
import { InlineNotification } from '@renderer/components/Nethesis/InlineNotification'
2626

2727
type DeviceType = 'audioInput' | 'audioOutput' | 'videoInput'
28+
29+
type DeviceError = {
30+
type: 'permission' | 'enumeration' | 'validation'
31+
message: string
32+
retryable: boolean
33+
}
34+
2835
const LOCALSTORAGE_KEYS = {
2936
audioInput: 'phone-island-audio-input-device',
3037
audioOutput: 'phone-island-audio-output-device',
@@ -66,6 +73,9 @@ export function SettingsDeviceDialog() {
6673
audioOutput: MediaDeviceInfo[]
6774
videoInput: MediaDeviceInfo[]
6875
} | null>(null)
76+
const [deviceError, setDeviceError] = useState<DeviceError | null>(null)
77+
const [isLoadingDevices, setIsLoadingDevices] = useState(false)
78+
const [retryCount, setRetryCount] = useState(0)
6979

7080
const schema: z.ZodType<PreferredDevices> = z.object({
7181
audioInput: z.string(),
@@ -132,24 +142,73 @@ export function SettingsDeviceDialog() {
132142
[devices],
133143
)
134144

135-
const initDevices = async () => {
136-
const devices = await getMediaDevices()
137-
Log.info('Available devices:', devices)
138-
setDevices(devices)
145+
const initDevices = async (isRetry = false) => {
146+
if (isRetry) {
147+
setRetryCount(prev => prev + 1)
148+
}
149+
150+
setIsLoadingDevices(true)
151+
setDeviceError(null)
152+
153+
try {
154+
const devices = await getMediaDevices()
155+
Log.info('Available devices:', devices)
156+
setDevices(devices)
157+
158+
if (devices && Object.values(devices).every(deviceList => deviceList.length === 0)) {
159+
setDeviceError({
160+
type: 'validation',
161+
message: t('Devices.No devices found'),
162+
retryable: true
163+
})
164+
}
165+
} catch (error) {
166+
Log.error('Failed to initialize devices:', error)
167+
setDeviceError({
168+
type: 'enumeration',
169+
message: t('Devices.Failed to load devices'),
170+
retryable: true
171+
})
172+
} finally {
173+
setIsLoadingDevices(false)
174+
}
139175
}
140176

141177
async function getMediaDevices() {
178+
if (!navigator.mediaDevices || !navigator.mediaDevices.enumerateDevices) {
179+
throw new Error(t('Devices.Media devices not supported') || 'Media devices not supported')
180+
}
181+
142182
try {
183+
// Request permissions first to get device labels
184+
try {
185+
await navigator.mediaDevices.getUserMedia({ audio: true, video: true })
186+
} catch (permissionError) {
187+
Log.warning('Media permissions not granted, device labels may be limited:', permissionError)
188+
// Don't set error, just log warning - continue to enumerate devices
189+
}
190+
143191
const devices = await navigator.mediaDevices.enumerateDevices()
144192

193+
if (!devices || devices.length === 0) {
194+
throw new Error(t('Devices.No media devices found') || 'No media devices found')
195+
}
196+
145197
const audioInput = devices.filter((device) => device.kind === 'audioinput')
146198
const audioOutput = devices.filter((device) => device.kind === 'audiooutput')
147199
const videoInput = devices.filter((device) => device.kind === 'videoinput')
148200

149-
return { audioInput, audioOutput, videoInput }
201+
// Validate device availability
202+
const validatedDevices = {
203+
audioInput: audioInput.filter(device => device.deviceId && device.deviceId !== 'default'),
204+
audioOutput: audioOutput.filter(device => device.deviceId && device.deviceId !== 'default'),
205+
videoInput: videoInput.filter(device => device.deviceId && device.deviceId !== 'default')
206+
}
207+
208+
return validatedDevices
150209
} catch (err) {
151-
console.error('Error reading audio and video devices:', err)
152-
return null
210+
Log.error('Error reading audio and video devices:', err)
211+
throw err
153212
}
154213
}
155214

@@ -189,6 +248,18 @@ export function SettingsDeviceDialog() {
189248
account?.data?.default_device?.type == 'webrtc' ||
190249
account?.data?.mainPresence !== 'online'
191250

251+
const handleRetryDevices = () => {
252+
if (retryCount < 3) {
253+
initDevices(true)
254+
} else {
255+
setDeviceError({
256+
type: 'enumeration',
257+
message: t('Devices.Max retry attempts reached'),
258+
retryable: false
259+
})
260+
}
261+
}
262+
192263
const DeviceDropdown = useCallback(
193264
({ name }: { name: DeviceType }) => {
194265
return (
@@ -295,9 +366,50 @@ export function SettingsDeviceDialog() {
295366
className='flex flex-col gap-1'
296367
>
297368
{/* Input field with clear button next to it */}
298-
<DeviceDropdown name='audioInput' />
299-
<DeviceDropdown name='audioOutput' />
300-
<DeviceDropdown name='videoInput' />
369+
{isLoadingDevices ? (
370+
<div className='flex items-center justify-center py-8'>
371+
<div className='animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500'></div>
372+
<span className='ml-2 text-gray-600 dark:text-gray-400'>
373+
{t('Devices.Loading devices...')}
374+
</span>
375+
</div>
376+
) : deviceError ? (
377+
<InlineNotification
378+
title={t('Common.Error')}
379+
type='error'
380+
className='mb-4'
381+
>
382+
<div className='flex flex-col gap-2'>
383+
<p>{deviceError.message}</p>
384+
{deviceError.retryable && (
385+
<Button
386+
variant='ghost'
387+
size='small'
388+
onClick={handleRetryDevices}
389+
disabled={retryCount >= 3}
390+
className='self-start'
391+
>
392+
{retryCount >= 3 ? t('Devices.Max retries reached') : t('Common.Try again')}
393+
{retryCount > 0 && `(${retryCount}/3)`}
394+
</Button>
395+
)}
396+
</div>
397+
</InlineNotification>
398+
) : devices ? (
399+
<>
400+
<DeviceDropdown name='audioInput' />
401+
<DeviceDropdown name='audioOutput' />
402+
<DeviceDropdown name='videoInput' />
403+
</>
404+
) : (
405+
<InlineNotification
406+
title={t('Common.Warning')}
407+
type='warning'
408+
className='mb-4'
409+
>
410+
<p>{t('Devices.No devices available')}</p>
411+
</InlineNotification>
412+
)}
301413

302414
{/* Inline notification */}
303415
{isDeviceUnavailable && (

0 commit comments

Comments
 (0)