Prevent voice dictation tip after settings toggle (#4519)
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
parent
92d8946209
commit
b7348e6c6b
|
|
@ -131,6 +131,7 @@
|
|||
"electron-builder": "^26.8.1",
|
||||
"electron-vite": "^5.0.0",
|
||||
"emoji-picker-react": "^4.19.1",
|
||||
"happy-dom": "^20.9.0",
|
||||
"html-to-image": "^1.11.13",
|
||||
"husky": "^9.1.7",
|
||||
"katex": "^0.16.45",
|
||||
|
|
|
|||
|
|
@ -215,6 +215,9 @@ importers:
|
|||
emoji-picker-react:
|
||||
specifier: ^4.19.1
|
||||
version: 4.19.1(react@19.2.5)
|
||||
happy-dom:
|
||||
specifier: ^20.9.0
|
||||
version: 20.9.0
|
||||
html-to-image:
|
||||
specifier: ^1.11.13
|
||||
version: 1.11.13
|
||||
|
|
@ -328,7 +331,7 @@ importers:
|
|||
version: 7.3.2(@types/node@25.6.0)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4)
|
||||
vitest:
|
||||
specifier: ^4.1.5
|
||||
version: 4.1.5(@types/node@25.6.0)(msw@2.14.3(@types/node@25.6.0)(typescript@5.9.3))(vite@7.3.2(@types/node@25.6.0)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4))
|
||||
version: 4.1.5(@types/node@25.6.0)(happy-dom@20.9.0)(msw@2.14.3(@types/node@25.6.0)(typescript@5.9.3))(vite@7.3.2(@types/node@25.6.0)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4))
|
||||
zustand:
|
||||
specifier: ^5.0.13
|
||||
version: 5.0.13(@types/react@19.2.14)(react@19.2.5)(use-sync-external-store@1.6.0(react@19.2.5))
|
||||
|
|
@ -2954,6 +2957,9 @@ packages:
|
|||
'@types/verror@1.10.11':
|
||||
resolution: {integrity: sha512-RlDm9K7+o5stv0Co8i8ZRGxDbrTxhJtgjqjFyVh/tXQyl/rYtTKlnTvZ88oSTeYREWurwx20Js4kTuKCsFkUtg==}
|
||||
|
||||
'@types/whatwg-mimetype@3.0.2':
|
||||
resolution: {integrity: sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==}
|
||||
|
||||
'@types/ws@8.18.1':
|
||||
resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==}
|
||||
|
||||
|
|
@ -3894,6 +3900,10 @@ packages:
|
|||
resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==}
|
||||
engines: {node: '>=0.12'}
|
||||
|
||||
entities@7.0.1:
|
||||
resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==}
|
||||
engines: {node: '>=0.12'}
|
||||
|
||||
env-paths@2.2.1:
|
||||
resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==}
|
||||
engines: {node: '>=6'}
|
||||
|
|
@ -4251,6 +4261,10 @@ packages:
|
|||
hachure-fill@0.5.2:
|
||||
resolution: {integrity: sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg==}
|
||||
|
||||
happy-dom@20.9.0:
|
||||
resolution: {integrity: sha512-GZZ9mKe8r646NUAf/zemnGbjYh4Bt8/MqASJY+pSm5ZDtc3YQox+4gsLI7yi1hba6o+eCsGxpHn5+iEVn31/FQ==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
has-flag@4.0.0:
|
||||
resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==}
|
||||
engines: {node: '>=8'}
|
||||
|
|
@ -6274,6 +6288,10 @@ packages:
|
|||
resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==}
|
||||
engines: {node: '>= 8'}
|
||||
|
||||
whatwg-mimetype@3.0.0:
|
||||
resolution: {integrity: sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
which-module@2.0.1:
|
||||
resolution: {integrity: sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==}
|
||||
|
||||
|
|
@ -8824,6 +8842,8 @@ snapshots:
|
|||
'@types/verror@1.10.11':
|
||||
optional: true
|
||||
|
||||
'@types/whatwg-mimetype@3.0.2': {}
|
||||
|
||||
'@types/ws@8.18.1':
|
||||
dependencies:
|
||||
'@types/node': 25.6.0
|
||||
|
|
@ -9835,6 +9855,8 @@ snapshots:
|
|||
|
||||
entities@6.0.1: {}
|
||||
|
||||
entities@7.0.1: {}
|
||||
|
||||
env-paths@2.2.1: {}
|
||||
|
||||
env-paths@3.0.0: {}
|
||||
|
|
@ -10289,6 +10311,18 @@ snapshots:
|
|||
|
||||
hachure-fill@0.5.2: {}
|
||||
|
||||
happy-dom@20.9.0:
|
||||
dependencies:
|
||||
'@types/node': 25.6.0
|
||||
'@types/whatwg-mimetype': 3.0.2
|
||||
'@types/ws': 8.18.1
|
||||
entities: 7.0.1
|
||||
whatwg-mimetype: 3.0.0
|
||||
ws: 8.20.0
|
||||
transitivePeerDependencies:
|
||||
- bufferutil
|
||||
- utf-8-validate
|
||||
|
||||
has-flag@4.0.0: {}
|
||||
|
||||
has-property-descriptors@1.0.2:
|
||||
|
|
@ -12677,7 +12711,7 @@ snapshots:
|
|||
lightningcss: 1.32.0
|
||||
yaml: 2.8.4
|
||||
|
||||
vitest@4.1.5(@types/node@25.6.0)(msw@2.14.3(@types/node@25.6.0)(typescript@5.9.3))(vite@7.3.2(@types/node@25.6.0)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4)):
|
||||
vitest@4.1.5(@types/node@25.6.0)(happy-dom@20.9.0)(msw@2.14.3(@types/node@25.6.0)(typescript@5.9.3))(vite@7.3.2(@types/node@25.6.0)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4)):
|
||||
dependencies:
|
||||
'@vitest/expect': 4.1.5
|
||||
'@vitest/mocker': 4.1.5(msw@2.14.3(@types/node@25.6.0)(typescript@5.9.3))(vite@7.3.2(@types/node@25.6.0)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4))
|
||||
|
|
@ -12701,6 +12735,7 @@ snapshots:
|
|||
why-is-node-running: 2.3.0
|
||||
optionalDependencies:
|
||||
'@types/node': 25.6.0
|
||||
happy-dom: 20.9.0
|
||||
transitivePeerDependencies:
|
||||
- msw
|
||||
|
||||
|
|
@ -12710,6 +12745,8 @@ snapshots:
|
|||
|
||||
web-streams-polyfill@3.3.3: {}
|
||||
|
||||
whatwg-mimetype@3.0.0: {}
|
||||
|
||||
which-module@2.0.1: {}
|
||||
|
||||
which@2.0.2:
|
||||
|
|
|
|||
|
|
@ -137,6 +137,22 @@ describe('feature tip startup gate', () => {
|
|||
).toEqual({ kind: 'skip' })
|
||||
})
|
||||
|
||||
it('does not open the voice tip after Settings marked it seen and dictation is disabled', () => {
|
||||
expect(
|
||||
getFeatureTipsAppOpenDecision({
|
||||
activeModal: 'none',
|
||||
cliInstalled: true,
|
||||
featureTipsSeenIds: ['voice-dictation'],
|
||||
featureInteractions: {},
|
||||
onboarding: existingUserOnboarding,
|
||||
persistedUIReady: true,
|
||||
promptedThisSession: false,
|
||||
settings: makeSettings(false),
|
||||
suppressedByOnboardingThisSession: false
|
||||
})
|
||||
).toEqual({ kind: 'skip' })
|
||||
})
|
||||
|
||||
it('does not open the CLI tip after the CLI is installed', () => {
|
||||
expect(
|
||||
getFeatureTipsAppOpenDecision({
|
||||
|
|
|
|||
|
|
@ -0,0 +1,225 @@
|
|||
// @vitest-environment happy-dom
|
||||
|
||||
import { act } from 'react'
|
||||
import { createRoot, type Root } from 'react-dom/client'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { DeveloperPermissionRequestResult } from '../../../../shared/developer-permissions-types'
|
||||
import type { GlobalSettings } from '../../../../shared/types'
|
||||
import { getDefaultVoiceSettings } from '../../../../shared/constants'
|
||||
import { handleVoiceDictationToggle, VoicePane } from './VoicePane'
|
||||
|
||||
const { useAppStoreMock, useShortcutLabelMock } = vi.hoisted(() => ({
|
||||
useAppStoreMock: vi.fn(),
|
||||
useShortcutLabelMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/store', () => ({ useAppStore: useAppStoreMock }))
|
||||
|
||||
vi.mock('@/hooks/useShortcutLabel', () => ({
|
||||
useShortcutLabel: useShortcutLabelMock
|
||||
}))
|
||||
|
||||
vi.mock('sonner', () => ({
|
||||
toast: {
|
||||
error: vi.fn(),
|
||||
message: vi.fn(),
|
||||
success: vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
const deniedMicrophoneResult: DeveloperPermissionRequestResult = {
|
||||
id: 'microphone',
|
||||
status: 'denied',
|
||||
openedSystemSettings: false
|
||||
}
|
||||
|
||||
function makeSettings(voiceEnabled: boolean): GlobalSettings {
|
||||
return {
|
||||
voice: {
|
||||
...getDefaultVoiceSettings(),
|
||||
enabled: voiceEnabled
|
||||
}
|
||||
} as GlobalSettings
|
||||
}
|
||||
|
||||
function installWindowApi(
|
||||
requestMicrophonePermission: () => Promise<DeveloperPermissionRequestResult>
|
||||
) {
|
||||
Object.assign(window, {
|
||||
api: {
|
||||
developerPermissions: {
|
||||
request: vi.fn(requestMicrophonePermission)
|
||||
},
|
||||
speech: {
|
||||
getCatalog: vi.fn(async () => []),
|
||||
onDownloadProgress: vi.fn(() => () => {}),
|
||||
downloadModel: vi.fn()
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function renderVoicePane(args: {
|
||||
voiceEnabled: boolean
|
||||
markFeatureTipsSeen: (ids: string[]) => void
|
||||
updateSettings: (updates: Partial<GlobalSettings>) => void
|
||||
requestMicrophonePermission?: () => Promise<DeveloperPermissionRequestResult>
|
||||
recordFeatureInteraction?: (id: string) => void
|
||||
}): Promise<{ button: HTMLButtonElement; root: Root; container: HTMLDivElement }> {
|
||||
const refreshModelStates = vi.fn()
|
||||
useAppStoreMock.mockImplementation((selector: (state: Record<string, unknown>) => unknown) =>
|
||||
selector({
|
||||
modelStates: [],
|
||||
refreshModelStates,
|
||||
markFeatureTipsSeen: args.markFeatureTipsSeen,
|
||||
recordFeatureInteraction: args.recordFeatureInteraction ?? vi.fn()
|
||||
})
|
||||
)
|
||||
useShortcutLabelMock.mockReturnValue('Ctrl+Shift+Y')
|
||||
installWindowApi(args.requestMicrophonePermission ?? vi.fn(async () => deniedMicrophoneResult))
|
||||
|
||||
const container = document.createElement('div')
|
||||
document.body.appendChild(container)
|
||||
const root = createRoot(container)
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<VoicePane settings={makeSettings(args.voiceEnabled)} updateSettings={args.updateSettings} />
|
||||
)
|
||||
})
|
||||
|
||||
const button = container.querySelector<HTMLButtonElement>('button[role="switch"]')
|
||||
if (!button) {
|
||||
throw new Error('Voice Dictation switch was not rendered')
|
||||
}
|
||||
|
||||
return { button, root, container }
|
||||
}
|
||||
|
||||
async function clickSwitch(button: HTMLButtonElement): Promise<void> {
|
||||
await act(async () => {
|
||||
button.dispatchEvent(new MouseEvent('click', { bubbles: true }))
|
||||
})
|
||||
await act(async () => {
|
||||
await Promise.resolve()
|
||||
})
|
||||
}
|
||||
|
||||
describe('VoicePane dictation switch', () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
useAppStoreMock.mockReset()
|
||||
useShortcutLabelMock.mockReset()
|
||||
})
|
||||
|
||||
it('clicking the switch marks the voice tip seen before disabling voice settings', async () => {
|
||||
const calls: string[] = []
|
||||
const requestMicrophonePermission = vi.fn()
|
||||
const updateVoiceSettings = vi.fn((updates: { enabled?: boolean }) => {
|
||||
calls.push(`settings:${String(updates.enabled)}`)
|
||||
})
|
||||
|
||||
await handleVoiceDictationToggle({
|
||||
voiceEnabled: true,
|
||||
markFeatureTipsSeen: (ids) => calls.push(`seen:${ids.join(',')}`),
|
||||
updateVoiceSettings,
|
||||
requestMicrophonePermission
|
||||
})
|
||||
|
||||
expect(calls).toEqual(['seen:voice-dictation', 'settings:false'])
|
||||
expect(updateVoiceSettings).toHaveBeenCalledWith({ enabled: false })
|
||||
expect(requestMicrophonePermission).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('clicking the switch marks the voice tip seen before the disable settings update', async () => {
|
||||
const calls: string[] = []
|
||||
const updateSettings = vi.fn((updates: Partial<GlobalSettings>) => {
|
||||
calls.push(`settings:${String(updates.voice?.enabled)}`)
|
||||
})
|
||||
const { button, root } = await renderVoicePane({
|
||||
voiceEnabled: true,
|
||||
markFeatureTipsSeen: (ids) => calls.push(`seen:${ids.join(',')}`),
|
||||
updateSettings,
|
||||
requestMicrophonePermission: vi.fn(async () => deniedMicrophoneResult)
|
||||
})
|
||||
|
||||
await clickSwitch(button)
|
||||
root.unmount()
|
||||
|
||||
expect(calls).toEqual(['seen:voice-dictation', 'settings:false'])
|
||||
expect(updateSettings).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
voice: expect.objectContaining({ enabled: false })
|
||||
})
|
||||
)
|
||||
expect(window.api.developerPermissions.request).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('clicking the switch marks the voice tip seen before requesting microphone permission', async () => {
|
||||
const calls: string[] = []
|
||||
const updateSettings = vi.fn((updates: Partial<GlobalSettings>) => {
|
||||
calls.push(`settings:${String(updates.voice?.enabled)}`)
|
||||
})
|
||||
const { button, root } = await renderVoicePane({
|
||||
voiceEnabled: false,
|
||||
markFeatureTipsSeen: (ids) => calls.push(`seen:${ids.join(',')}`),
|
||||
updateSettings,
|
||||
requestMicrophonePermission: async () => {
|
||||
calls.push('permission-request')
|
||||
return deniedMicrophoneResult
|
||||
}
|
||||
})
|
||||
|
||||
await clickSwitch(button)
|
||||
root.unmount()
|
||||
|
||||
expect(calls).toEqual(['seen:voice-dictation', 'permission-request'])
|
||||
expect(updateSettings).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('marks the voice tip seen before requesting microphone permission when enabling is denied', async () => {
|
||||
const calls: string[] = []
|
||||
const updateVoiceSettings = vi.fn((updates: { enabled?: boolean }) => {
|
||||
calls.push(`settings:${String(updates.enabled)}`)
|
||||
})
|
||||
|
||||
await handleVoiceDictationToggle({
|
||||
voiceEnabled: false,
|
||||
markFeatureTipsSeen: (ids) => calls.push(`seen:${ids.join(',')}`),
|
||||
updateVoiceSettings,
|
||||
requestMicrophonePermission: async () => {
|
||||
calls.push('permission-request')
|
||||
return deniedMicrophoneResult
|
||||
},
|
||||
setPermissionPending: (pending) => calls.push(`pending:${String(pending)}`),
|
||||
notifyPermissionRequired: () => calls.push('permission-required')
|
||||
})
|
||||
|
||||
expect(calls).toEqual([
|
||||
'seen:voice-dictation',
|
||||
'pending:true',
|
||||
'permission-request',
|
||||
'permission-required',
|
||||
'pending:false'
|
||||
])
|
||||
expect(updateVoiceSettings).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not record voice feature interaction from the settings switch', async () => {
|
||||
const recordFeatureInteraction = vi.fn()
|
||||
const { button, root } = await renderVoicePane({
|
||||
voiceEnabled: true,
|
||||
markFeatureTipsSeen: vi.fn(),
|
||||
updateSettings: vi.fn(),
|
||||
recordFeatureInteraction
|
||||
})
|
||||
|
||||
await clickSwitch(button)
|
||||
root.unmount()
|
||||
|
||||
expect(recordFeatureInteraction).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
|
@ -1,7 +1,13 @@
|
|||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import type { GlobalSettings } from '../../../../shared/types'
|
||||
import { getDefaultVoiceSettings } from '../../../../shared/constants'
|
||||
import type { SpeechModelManifest, SpeechModelState } from '../../../../shared/speech-types'
|
||||
import type { DeveloperPermissionRequestResult } from '../../../../shared/developer-permissions-types'
|
||||
import type { FeatureTipId } from '../../../../shared/feature-tips'
|
||||
import type {
|
||||
SpeechModelManifest,
|
||||
SpeechModelState,
|
||||
VoiceSettings
|
||||
} from '../../../../shared/speech-types'
|
||||
import { Button } from '../ui/button'
|
||||
import { Label } from '../ui/label'
|
||||
import { Separator } from '../ui/separator'
|
||||
|
|
@ -21,6 +27,65 @@ type VoicePaneProps = {
|
|||
updateSettings: (updates: Partial<GlobalSettings>) => void
|
||||
}
|
||||
|
||||
type VoiceDictationToggleOptions = {
|
||||
voiceEnabled: boolean
|
||||
markFeatureTipsSeen: (ids: FeatureTipId[]) => void
|
||||
updateVoiceSettings: (updates: Partial<VoiceSettings>) => void
|
||||
requestMicrophonePermission: () => Promise<DeveloperPermissionRequestResult>
|
||||
setPermissionPending?: (pending: boolean) => void
|
||||
isMounted?: () => boolean
|
||||
notifyPermissionGranted?: () => void
|
||||
notifyPermissionOpenedSystemSettings?: () => void
|
||||
notifyPermissionRequired?: () => void
|
||||
notifyPermissionRequestFailed?: () => void
|
||||
}
|
||||
|
||||
export async function handleVoiceDictationToggle({
|
||||
voiceEnabled,
|
||||
markFeatureTipsSeen,
|
||||
updateVoiceSettings,
|
||||
requestMicrophonePermission,
|
||||
setPermissionPending,
|
||||
isMounted,
|
||||
notifyPermissionGranted,
|
||||
notifyPermissionOpenedSystemSettings,
|
||||
notifyPermissionRequired,
|
||||
notifyPermissionRequestFailed
|
||||
}: VoiceDictationToggleOptions): Promise<void> {
|
||||
// Why: changing the Voice Dictation switch proves the user discovered the
|
||||
// feature; disabling it later should not make the discovery modal eligible.
|
||||
markFeatureTipsSeen(['voice-dictation'])
|
||||
|
||||
if (voiceEnabled) {
|
||||
updateVoiceSettings({ enabled: false })
|
||||
return
|
||||
}
|
||||
|
||||
setPermissionPending?.(true)
|
||||
try {
|
||||
// Why: enabling dictation is the point where users expect the macOS
|
||||
// microphone prompt, not after their first attempted recording fails.
|
||||
const result = await requestMicrophonePermission()
|
||||
if (result.status === 'granted' || result.status === 'unsupported') {
|
||||
updateVoiceSettings({ enabled: true })
|
||||
}
|
||||
|
||||
if (result.status === 'granted') {
|
||||
notifyPermissionGranted?.()
|
||||
} else if (result.openedSystemSettings) {
|
||||
notifyPermissionOpenedSystemSettings?.()
|
||||
} else if (result.status !== 'unsupported') {
|
||||
notifyPermissionRequired?.()
|
||||
}
|
||||
} catch {
|
||||
notifyPermissionRequestFailed?.()
|
||||
} finally {
|
||||
if (isMounted?.() ?? true) {
|
||||
setPermissionPending?.(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function VoicePane({ settings, updateSettings }: VoicePaneProps): React.JSX.Element {
|
||||
// Why: voice was made optional on GlobalSettings to keep older test fixtures
|
||||
// and pre-voice profiles type-compatible. Persistence merges defaults at
|
||||
|
|
@ -29,6 +94,7 @@ export function VoicePane({ settings, updateSettings }: VoicePaneProps): React.J
|
|||
const voiceSettings = settings.voice ?? getDefaultVoiceSettings()
|
||||
const modelStates = useAppStore((s) => s.modelStates)
|
||||
const refreshModelStates = useAppStore((s) => s.refreshModelStates)
|
||||
const markFeatureTipsSeen = useAppStore((s) => s.markFeatureTipsSeen)
|
||||
const shortcutLabel = useShortcutLabel('voice.dictation')
|
||||
const [catalog, setCatalog] = useState<SpeechModelManifest[]>([])
|
||||
const [permissionPending, setPermissionPending] = useState(false)
|
||||
|
|
@ -63,7 +129,7 @@ export function VoicePane({ settings, updateSettings }: VoicePaneProps): React.J
|
|||
return cleanup
|
||||
}, [refreshModelStates])
|
||||
|
||||
const updateVoiceSettings = (updates: Partial<GlobalSettings['voice']>): void => {
|
||||
const updateVoiceSettings = (updates: Partial<VoiceSettings>): void => {
|
||||
updateSettings({
|
||||
voice: {
|
||||
...voiceSettings,
|
||||
|
|
@ -73,36 +139,24 @@ export function VoicePane({ settings, updateSettings }: VoicePaneProps): React.J
|
|||
}
|
||||
|
||||
const toggleVoiceDictation = async (): Promise<void> => {
|
||||
if (voiceSettings.enabled) {
|
||||
updateVoiceSettings({ enabled: false })
|
||||
return
|
||||
}
|
||||
|
||||
setPermissionPending(true)
|
||||
try {
|
||||
// Why: enabling dictation is the point where users expect the macOS
|
||||
// microphone prompt, not after their first attempted recording fails.
|
||||
const result = await window.api.developerPermissions.request({ id: 'microphone' })
|
||||
if (result.status === 'granted' || result.status === 'unsupported') {
|
||||
updateVoiceSettings({ enabled: true })
|
||||
}
|
||||
|
||||
if (result.status === 'granted') {
|
||||
toast.success('Microphone permission granted')
|
||||
} else if (result.openedSystemSettings) {
|
||||
await handleVoiceDictationToggle({
|
||||
voiceEnabled: voiceSettings.enabled,
|
||||
markFeatureTipsSeen,
|
||||
updateVoiceSettings,
|
||||
requestMicrophonePermission: () =>
|
||||
window.api.developerPermissions.request({ id: 'microphone' }),
|
||||
setPermissionPending,
|
||||
isMounted: () => mountedRef.current,
|
||||
notifyPermissionGranted: () => toast.success('Microphone permission granted'),
|
||||
notifyPermissionOpenedSystemSettings: () =>
|
||||
toast.message(
|
||||
'Opened macOS Privacy & Security. Enable dictation again after granting access.'
|
||||
)
|
||||
} else if (result.status !== 'unsupported') {
|
||||
toast.message('Microphone permission is required before enabling voice dictation.')
|
||||
}
|
||||
} catch {
|
||||
toast.error('Could not request microphone permission. Voice dictation was not enabled.')
|
||||
} finally {
|
||||
if (mountedRef.current) {
|
||||
setPermissionPending(false)
|
||||
}
|
||||
}
|
||||
),
|
||||
notifyPermissionRequired: () =>
|
||||
toast.message('Microphone permission is required before enabling voice dictation.'),
|
||||
notifyPermissionRequestFailed: () =>
|
||||
toast.error('Could not request microphone permission. Voice dictation was not enabled.')
|
||||
})
|
||||
}
|
||||
|
||||
const getModelState = (id: string): SpeechModelState | undefined =>
|
||||
|
|
|
|||
Loading…
Reference in New Issue