Skip to content

Commit f9b7afc

Browse files
committed
fix(chat): guide blocked browser microphone access
1 parent 8142208 commit f9b7afc

5 files changed

Lines changed: 134 additions & 14 deletions

File tree

apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ export {
2323
} from './constants'
2424
export { DropOverlay } from './drop-overlay'
2525
export { MicButton } from './mic-button'
26+
export { MicrophonePermissionHelp } from './microphone-permission-help'
2627
export { PlusMenuDropdown } from './plus-menu-dropdown'
2728
export type {
2829
PromptEditorInstance,
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { MicrophonePermissionHelp } from './microphone-permission-help'
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
/**
2+
* @vitest-environment jsdom
3+
*/
4+
import { act, type ReactNode } from 'react'
5+
import { createRoot, type Root } from 'react-dom/client'
6+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
7+
8+
vi.mock('@sim/emcn', () => ({
9+
ChipModal: ({ open, children }: { open: boolean; children: ReactNode }) =>
10+
open ? <div role='dialog'>{children}</div> : null,
11+
ChipModalBody: ({ children }: { children: ReactNode }) => <div>{children}</div>,
12+
ChipModalHeader: ({ children, onClose }: { children: ReactNode; onClose: () => void }) => (
13+
<header>
14+
{children}
15+
<button type='button' onClick={onClose}>
16+
Close
17+
</button>
18+
</header>
19+
),
20+
}))
21+
22+
import { MicrophonePermissionHelp } from '@/app/workspace/[workspaceId]/home/components/user-input/components/microphone-permission-help/microphone-permission-help'
23+
24+
let container: HTMLDivElement
25+
let root: Root
26+
27+
beforeEach(() => {
28+
globalThis.IS_REACT_ACT_ENVIRONMENT = true
29+
container = document.createElement('div')
30+
document.body.appendChild(container)
31+
root = createRoot(container)
32+
})
33+
34+
afterEach(() => {
35+
act(() => root.unmount())
36+
container.remove()
37+
})
38+
39+
function renderPermissionHelp(onOpenChange = vi.fn()) {
40+
act(() => root.render(<MicrophonePermissionHelp open onOpenChange={onOpenChange} />))
41+
return onOpenChange
42+
}
43+
44+
describe('MicrophonePermissionHelp', () => {
45+
it('explains how to recover blocked browser microphone access', () => {
46+
renderPermissionHelp()
47+
48+
expect(container.textContent).toContain('Open the site controls beside the address bar.')
49+
expect(container.textContent).toContain('Set Microphone access for this site to Allow.')
50+
expect(container.textContent).toContain('Safari Settings')
51+
})
52+
53+
it('closes from the header action', () => {
54+
const onOpenChange = renderPermissionHelp()
55+
const closeButton = container.querySelector('button')
56+
57+
act(() => closeButton?.click())
58+
59+
expect(onOpenChange).toHaveBeenCalledWith(false)
60+
})
61+
})
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
'use client'
2+
3+
import { useId } from 'react'
4+
import { ChipModal, ChipModalBody, ChipModalHeader } from '@sim/emcn'
5+
6+
interface MicrophonePermissionHelpProps {
7+
open: boolean
8+
onOpenChange: (open: boolean) => void
9+
}
10+
11+
export function MicrophonePermissionHelp({ open, onOpenChange }: MicrophonePermissionHelpProps) {
12+
const descriptionId = useId()
13+
const handleClose = () => onOpenChange(false)
14+
15+
return (
16+
<ChipModal
17+
open={open}
18+
onOpenChange={onOpenChange}
19+
srTitle='Allow microphone access'
20+
aria-describedby={descriptionId}
21+
size='sm'
22+
>
23+
<ChipModalHeader onClose={handleClose}>Allow microphone access</ChipModalHeader>
24+
<ChipModalBody>
25+
<div className='flex flex-col gap-3 px-2 text-sm'>
26+
<p id={descriptionId} className='text-[var(--text-secondary)]'>
27+
Once microphone access is blocked, your browser requires you to change it from the site
28+
controls.
29+
</p>
30+
<ol className='flex list-decimal flex-col gap-2 pl-5 text-[var(--text-body)]'>
31+
<li>Open the site controls beside the address bar.</li>
32+
<li>Set Microphone access for this site to Allow.</li>
33+
<li>Reload the page if prompted, then try voice input again.</li>
34+
</ol>
35+
<p className='text-[var(--text-tertiary)]'>
36+
In Safari, open Safari Settings, then Websites, then Microphone.
37+
</p>
38+
</div>
39+
</ChipModalBody>
40+
</ChipModal>
41+
)
42+
}

apps/sim/app/workspace/[workspaceId]/home/components/user-input/user-input.tsx

Lines changed: 29 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import {
2424
AttachedFilesList,
2525
DropOverlay,
2626
MicButton,
27+
MicrophonePermissionHelp,
2728
PromptEditor,
2829
SendButton,
2930
usePromptEditor,
@@ -102,6 +103,7 @@ const UserInputImpl = forwardRef<UserInputHandle, UserInputProps>(function UserI
102103
const { workspaceId } = useParams<{ workspaceId: string }>()
103104
const { navigateToSettings } = useSettingsNavigation()
104105
const { userId, onContextAdd, onContextRemove } = useChatSurface()
106+
const [microphonePermissionHelpOpen, setMicrophonePermissionHelpOpen] = useState(false)
105107

106108
const [initialValue] = useState(() => {
107109
if (defaultValue) return defaultValue
@@ -323,19 +325,27 @@ const UserInputImpl = forwardRef<UserInputHandle, UserInputProps>(function UserI
323325
function handleSpeechError(error: SpeechToTextError) {
324326
if (error === 'microphone-blocked') {
325327
const desktopBridge = getDesktopBridge()
326-
toast.error(
327-
desktopBridge
328-
? 'Microphone access is blocked. Allow Sim to use the microphone in your system privacy settings.'
329-
: 'Microphone access is blocked. Allow it for this site and try again.',
330-
desktopBridge?.openMicrophoneSettings
331-
? {
332-
action: {
333-
label: 'Open Settings',
334-
onClick: () => void desktopBridge.openMicrophoneSettings?.(),
335-
},
336-
}
337-
: undefined
338-
)
328+
if (desktopBridge) {
329+
const { openMicrophoneSettings } = desktopBridge
330+
toast.error(
331+
'Microphone access is blocked. Allow Sim to use the microphone in your system privacy settings.',
332+
openMicrophoneSettings
333+
? {
334+
action: {
335+
label: 'Open Settings',
336+
onClick: () => void openMicrophoneSettings(),
337+
},
338+
}
339+
: undefined
340+
)
341+
} else {
342+
toast.error('Microphone access is blocked. Allow it for this site and try again.', {
343+
action: {
344+
label: 'Show steps',
345+
onClick: () => setMicrophonePermissionHelpOpen(true),
346+
},
347+
})
348+
}
339349
return
340350
}
341351
if (error === 'microphone-unavailable') {
@@ -504,7 +514,7 @@ const UserInputImpl = forwardRef<UserInputHandle, UserInputProps>(function UserI
504514

505515
const handleContainerClick = useCallback(
506516
(e: React.MouseEvent<HTMLDivElement>) => {
507-
if ((e.target as HTMLElement).closest('button')) return
517+
if ((e.target as HTMLElement).closest('button, [role="dialog"]')) return
508518
textareaRef.current?.focus()
509519
},
510520
[textareaRef]
@@ -697,6 +707,11 @@ const UserInputImpl = forwardRef<UserInputHandle, UserInputProps>(function UserI
697707
/>
698708

699709
{files.isDragging && <DropOverlay />}
710+
711+
<MicrophonePermissionHelp
712+
open={microphonePermissionHelpOpen}
713+
onOpenChange={setMicrophonePermissionHelpOpen}
714+
/>
700715
</div>
701716
)
702717
})

0 commit comments

Comments
 (0)