@adatechnology/conversations-ui 0.8.0 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +106 -0
- package/dist/{ConversationSimulatorPanel-q-wVgBCN.d.ts → ConversationSimulatorPanel-P6eqTSAO.d.ts} +1 -16
- package/dist/{chunk-HMCOTZAX.js → chunk-NSOAUYNE.js} +33 -28
- package/dist/{chunk-O6457XHK.js → chunk-WCZNIJ3M.js} +16 -1
- package/dist/{chunk-L3OFAQPS.js → chunk-Y326LLEP.js} +4 -14
- package/dist/chunk-ZSOD5AQT.js +19 -0
- package/dist/flows/index.js +4 -2
- package/dist/index.d.ts +10 -3
- package/dist/index.js +8 -6
- package/dist/participant/index.d.ts +54 -3
- package/dist/participant/index.js +1722 -418
- package/dist/preview/index.d.ts +2 -2
- package/dist/preview/index.js +2 -2
- package/dist/styles.css +281 -23
- package/package.json +3 -2
- package/src/AudioRecorderButton.tsx +4 -28
- package/src/Tooltip.tsx +5 -12
- package/src/TooltipDom.test.tsx +78 -0
- package/src/audioRecorderFormat.ts +27 -0
- package/src/domHarness.test-helper.ts +53 -0
- package/src/participant/ConversationThread.tsx +4 -0
- package/src/participant/ParticipantAttachmentItem.tsx +7 -10
- package/src/participant/ParticipantAudioAttachment.tsx +34 -0
- package/src/participant/ParticipantAudioPlayer.tsx +68 -0
- package/src/participant/ParticipantAudioPlayerControls.tsx +68 -0
- package/src/participant/ParticipantAudioRecorderView.tsx +55 -0
- package/src/participant/ParticipantAudioTranscription.tsx +90 -0
- package/src/participant/ParticipantBackButton.tsx +1 -1
- package/src/participant/ParticipantBubbleBody.tsx +54 -0
- package/src/participant/ParticipantBubbleStatus.tsx +22 -28
- package/src/participant/ParticipantChannelBadges.tsx +10 -2
- package/src/participant/ParticipantComposer.tsx +51 -16
- package/src/participant/ParticipantComposerAudio.tsx +95 -0
- package/src/participant/ParticipantComposerControls.tsx +2 -0
- package/src/participant/ParticipantConversations.tsx +3 -0
- package/src/participant/ParticipantCopyableToken.tsx +1 -0
- package/src/participant/ParticipantFailedMenu.tsx +40 -0
- package/src/participant/ParticipantFailedMenuList.tsx +41 -0
- package/src/participant/ParticipantMessageBubble.test.tsx +6 -6
- package/src/participant/ParticipantMessageBubble.tsx +20 -52
- package/src/participant/ParticipantMessageText.test.tsx +1 -1
- package/src/participant/ParticipantMessageText.tsx +4 -2
- package/src/participant/ParticipantProtocolBadge.tsx +1 -0
- package/src/participant/ParticipantRecorderParts.tsx +118 -0
- package/src/participant/ParticipantScrollToLatest.tsx +1 -1
- package/src/participant/ParticipantThread.tsx +30 -12
- package/src/participant/ParticipantThreadHeader.test.tsx +1 -1
- package/src/participant/ParticipantThreadScreen.tsx +1 -0
- package/src/participant/failedMenuKeyboard.ts +29 -0
- package/src/participant/index.ts +3 -0
- package/src/participant/participantAudio.types.ts +10 -0
- package/src/participant/participantAudioCapture.test.ts +15 -0
- package/src/participant/participantAudioCapture.ts +85 -0
- package/src/participant/participantAudioCoordinator.ts +13 -0
- package/src/participant/participantAudioFormat.test.ts +93 -0
- package/src/participant/participantAudioFormat.ts +72 -0
- package/src/participant/participantAudioGolden.test.tsx +168 -0
- package/src/participant/participantAudioLabels.test.ts +22 -0
- package/src/participant/participantAudioLabels.ts +76 -0
- package/src/participant/participantAudioPlayerControlsDom.test.tsx +123 -0
- package/src/participant/participantAudioPlayerCss.test.ts +63 -0
- package/src/participant/participantAudioPlayerDom.test.tsx +154 -0
- package/src/participant/participantAudioPlayerDomKit.test-helper.tsx +84 -0
- package/src/participant/participantAudioPlayerState.ts +35 -0
- package/src/participant/participantAudioRecorderDom.test.tsx +127 -0
- package/src/participant/participantAudioRecorderInterruptionDom.test.tsx +135 -0
- package/src/participant/participantAudioRecorderSpecDom.test.tsx +110 -0
- package/src/participant/participantAudioRecorderState.test.ts +144 -0
- package/src/participant/participantAudioRecorderState.ts +93 -0
- package/src/participant/participantAudioRecorderView.test.tsx +98 -0
- package/src/participant/participantAudioSupport.ts +36 -0
- package/src/participant/participantAudioTranscriptionDom.test.tsx +198 -0
- package/src/participant/participantComposerAudioDom.test.tsx +185 -0
- package/src/participant/participantComposerAudioGolden.test.tsx +68 -0
- package/src/participant/participantComposerAudioSsr.test.tsx +74 -0
- package/src/participant/participantFailedMenuDom.test.tsx +154 -0
- package/src/participant/participantFailedMenuState.test.ts +61 -0
- package/src/participant/participantFailedMenuState.ts +57 -0
- package/src/participant/participantFailedMessage.test.tsx +83 -0
- package/src/participant/participantFileValidation.ts +1 -1
- package/src/participant/participantGolden.test.tsx +2 -0
- package/src/participant/participantGoldenScenarios.test-helper.tsx +14 -1
- package/src/participant/participantLabels.ts +10 -1
- package/src/participant/participantMediaFakes.test-helper.ts +82 -0
- package/src/participant/participantNoMicrophoneDom.test.tsx +44 -0
- package/src/participant/participantPublicTypes.test.ts +11 -0
- package/src/participant/participantRecorderDomKit.test-helper.tsx +131 -0
- package/src/participant/participantRecorderFakes.test-helper.ts +47 -0
- package/src/participant/participantSpecRecorderFake.test-helper.ts +52 -0
- package/src/participant/participantThemeContrast.test.ts +17 -0
- package/src/participant/participantTooltips.test.tsx +74 -0
- package/src/participant/test-fixtures/composer-audio-denied.golden.html +1 -0
- package/src/participant/test-fixtures/composer-audio-idle.golden.html +1 -0
- package/src/participant/test-fixtures/composer-audio-recording.golden.html +1 -0
- package/src/participant/test-fixtures/composer-audio-review.golden.html +1 -0
- package/src/participant/test-fixtures/thread-audio-native.golden.html +1 -0
- package/src/participant/test-fixtures/thread-audio-player.golden.html +1 -0
- package/src/participant/test-fixtures/thread-audio-transcription-empty.golden.html +1 -0
- package/src/participant/test-fixtures/thread-audio-transcription-pending.golden.html +1 -0
- package/src/participant/test-fixtures/thread-audio-transcription-ready.golden.html +1 -0
- package/src/participant/test-fixtures/thread-audio-transcription-too_long.golden.html +1 -0
- package/src/participant/test-fixtures/thread-avatars-initials.golden.html +1 -1
- package/src/participant/test-fixtures/thread-closed.golden.html +1 -1
- package/src/participant/test-fixtures/thread-composer-microphone-idle.golden.html +1 -0
- package/src/participant/test-fixtures/thread-default.golden.html +1 -1
- package/src/participant/test-fixtures/thread-empty-draft.golden.html +1 -0
- package/src/participant/test-fixtures/thread-error.golden.html +1 -1
- package/src/participant/test-fixtures/thread-loading.golden.html +1 -1
- package/src/participant/test-fixtures/thread-plain.golden.html +1 -1
- package/src/participant/test-fixtures/thread-tail-off.golden.html +1 -1
- package/src/participant/useFailedMenuEffects.ts +59 -0
- package/src/participant/useParticipantAudioMediaEvents.ts +46 -0
- package/src/participant/useParticipantAudioPlayer.ts +52 -0
- package/src/participant/useParticipantAudioPlayerActions.ts +47 -0
- package/src/participant/useParticipantAudioRecorder.ts +153 -0
- package/src/participant/useParticipantAudioRecorderCore.ts +55 -0
- package/src/participant/useParticipantAudioRecorderEffects.ts +70 -0
- package/src/participant/useParticipantAudioSourceRetry.ts +42 -0
- package/src/participant/useParticipantFailedMenu.ts +60 -0
- package/src/participant/useParticipantSendAudio.test.tsx +85 -0
- package/src/participant/useParticipantSendAudio.ts +28 -0
- package/src/participant/useParticipantThreadController.ts +20 -7
- package/src/participant/useStableAttachmentUrl.ts +71 -0
- package/src/participant/useSuppressTouchTooltips.ts +38 -0
- package/src/participant/useTranscriptionOverflow.ts +25 -0
- package/src/participant/useTranscriptionReadyAnnouncement.ts +24 -0
- package/src/styles.css +48 -5
- package/src/tooltipLayerRegistry.test.ts +56 -0
- package/src/tooltipLayerRegistry.ts +34 -0
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { act } from 'react'
|
|
2
|
+
|
|
3
|
+
import { describe, expect, it } from 'bun:test'
|
|
4
|
+
|
|
5
|
+
import { setupRecorderDom } from './participantRecorderDomKit.test-helper'
|
|
6
|
+
|
|
7
|
+
const kit = setupRecorderDom()
|
|
8
|
+
const { mountProbe, start, advance, recordFor, deferStream, hidePage } = kit
|
|
9
|
+
|
|
10
|
+
describe('the duration ceiling', () => {
|
|
11
|
+
it('stops by itself at the ceiling and lands in review', async () => {
|
|
12
|
+
mountProbe({ maxDurationMs: 2000 })
|
|
13
|
+
await recordFor(1000)
|
|
14
|
+
expect(kit.api.status).toBe('recording')
|
|
15
|
+
await advance(1100)
|
|
16
|
+
expect(kit.api.status).toBe('review')
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
it('shows the elapsed time while recording', async () => {
|
|
20
|
+
mountProbe()
|
|
21
|
+
await recordFor(3000)
|
|
22
|
+
expect(kit.api.elapsedMs).toBeGreaterThanOrEqual(2750)
|
|
23
|
+
})
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
describe('cancellation token of getUserMedia (contract 2.1)', () => {
|
|
27
|
+
it('unmount while requesting: a late stream gets every track stopped at once', async () => {
|
|
28
|
+
deferStream()
|
|
29
|
+
const tree = mountProbe()
|
|
30
|
+
await start()
|
|
31
|
+
tree.unmount()
|
|
32
|
+
await act(async () => kit.resolveStream?.(kit.stream))
|
|
33
|
+
expect(kit.stream.track.stopCount).toBe(1)
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('page hidden while requesting: back to idle and the late kit.stream is stopped', async () => {
|
|
37
|
+
deferStream()
|
|
38
|
+
mountProbe()
|
|
39
|
+
await start()
|
|
40
|
+
hidePage()
|
|
41
|
+
expect(kit.api.status).toBe('idle')
|
|
42
|
+
await act(async () => kit.resolveStream?.(kit.stream))
|
|
43
|
+
expect(kit.stream.track.stopCount).toBe(1)
|
|
44
|
+
expect(kit.api.status).toBe('idle')
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('pagehide while requesting also cancels', async () => {
|
|
48
|
+
deferStream()
|
|
49
|
+
mountProbe()
|
|
50
|
+
await start()
|
|
51
|
+
act(() => {
|
|
52
|
+
window.dispatchEvent(new Event('pagehide'))
|
|
53
|
+
})
|
|
54
|
+
await act(async () => kit.resolveStream?.(kit.stream))
|
|
55
|
+
expect(kit.stream.track.stopCount).toBe(1)
|
|
56
|
+
expect(kit.api.status).toBe('idle')
|
|
57
|
+
})
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
describe('interruptions (contract 2.1)', () => {
|
|
61
|
+
it('track.onended while recording stops and goes to review', async () => {
|
|
62
|
+
mountProbe()
|
|
63
|
+
await recordFor(1000)
|
|
64
|
+
act(() => kit.stream.track.onended?.())
|
|
65
|
+
expect(kit.api.status).toBe('review')
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('a mute shorter than 3 s does not interrupt', async () => {
|
|
69
|
+
mountProbe()
|
|
70
|
+
await recordFor(1000)
|
|
71
|
+
act(() => kit.stream.track.onmute?.())
|
|
72
|
+
await advance(2900)
|
|
73
|
+
act(() => kit.stream.track.onunmute?.())
|
|
74
|
+
await advance(5000)
|
|
75
|
+
expect(kit.api.status).toBe('recording')
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
it('a mute that persists past 3 s interrupts', async () => {
|
|
79
|
+
mountProbe()
|
|
80
|
+
await recordFor(1000)
|
|
81
|
+
act(() => kit.stream.track.onmute?.())
|
|
82
|
+
await advance(2900)
|
|
83
|
+
expect(kit.api.status).toBe('recording')
|
|
84
|
+
await advance(200)
|
|
85
|
+
expect(kit.api.status).toBe('review')
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it('page hidden while recording stops and goes to review', async () => {
|
|
89
|
+
mountProbe()
|
|
90
|
+
await recordFor(1000)
|
|
91
|
+
hidePage()
|
|
92
|
+
expect(kit.api.status).toBe('review')
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
it('pagehide while recording stops and goes to review', async () => {
|
|
96
|
+
mountProbe()
|
|
97
|
+
await recordFor(1000)
|
|
98
|
+
act(() => {
|
|
99
|
+
window.dispatchEvent(new Event('pagehide'))
|
|
100
|
+
})
|
|
101
|
+
expect(kit.api.status).toBe('review')
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
it('page hidden in review pauses the player and KEEPS the recording', async () => {
|
|
105
|
+
mountProbe()
|
|
106
|
+
await recordFor(1000)
|
|
107
|
+
act(() => kit.api.stop())
|
|
108
|
+
let paused = 0
|
|
109
|
+
;(kit.api.audioRef as { current: unknown }).current = { pause: () => (paused += 1) }
|
|
110
|
+
hidePage()
|
|
111
|
+
expect(paused).toBe(1)
|
|
112
|
+
expect(kit.api.status).toBe('review')
|
|
113
|
+
expect(kit.api.recorded?.file.size).toBe(4096)
|
|
114
|
+
expect(kit.revoked).toEqual([])
|
|
115
|
+
})
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
describe('unmount discards everything', () => {
|
|
119
|
+
it('while recording: tracks stopped, capture reported off', async () => {
|
|
120
|
+
const tree = mountProbe()
|
|
121
|
+
await recordFor(1000)
|
|
122
|
+
tree.unmount()
|
|
123
|
+
expect(kit.stream.track.stopCount).toBeGreaterThan(0)
|
|
124
|
+
expect(kit.captureEvents.at(-1)).toBe(false)
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
it('in review: the object URL is revoked', async () => {
|
|
128
|
+
const tree = mountProbe()
|
|
129
|
+
await recordFor(1000)
|
|
130
|
+
act(() => kit.api.stop())
|
|
131
|
+
tree.unmount()
|
|
132
|
+
expect(kit.revoked).toEqual(['blob:fake-recording'])
|
|
133
|
+
expect(kit.captureEvents.at(-1)).toBe(false)
|
|
134
|
+
})
|
|
135
|
+
})
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { act } from 'react'
|
|
2
|
+
|
|
3
|
+
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, jest } from 'bun:test'
|
|
4
|
+
|
|
5
|
+
import { setupDom, type DomHarness } from '../domHarness.test-helper'
|
|
6
|
+
import { SpecMediaRecorder, SpecStream } from './participantSpecRecorderFake.test-helper'
|
|
7
|
+
import { useParticipantAudioRecorder } from './useParticipantAudioRecorder'
|
|
8
|
+
|
|
9
|
+
let harness: DomHarness
|
|
10
|
+
let api: ReturnType<typeof useParticipantAudioRecorder>
|
|
11
|
+
let stream: SpecStream
|
|
12
|
+
|
|
13
|
+
let probeMaxDurationMs: number | undefined
|
|
14
|
+
|
|
15
|
+
function Probe() {
|
|
16
|
+
api = useParticipantAudioRecorder(
|
|
17
|
+
probeMaxDurationMs === undefined ? {} : { options: { maxDurationMs: probeMaxDurationMs } },
|
|
18
|
+
)
|
|
19
|
+
return null
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async function flush(): Promise<void> {
|
|
23
|
+
await act(async () => {
|
|
24
|
+
await Promise.resolve()
|
|
25
|
+
await Promise.resolve()
|
|
26
|
+
})
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async function startAndRecord(): Promise<void> {
|
|
30
|
+
harness.mount(<Probe />)
|
|
31
|
+
await act(async () => {
|
|
32
|
+
void api.start()
|
|
33
|
+
})
|
|
34
|
+
await act(async () => {
|
|
35
|
+
jest.advanceTimersByTime(1000)
|
|
36
|
+
})
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function hidePage(): void {
|
|
40
|
+
Object.defineProperty(document, 'visibilityState', { configurable: true, get: () => 'hidden' })
|
|
41
|
+
act(() => {
|
|
42
|
+
document.dispatchEvent(new Event('visibilitychange'))
|
|
43
|
+
})
|
|
44
|
+
Object.defineProperty(document, 'visibilityState', { configurable: true, get: () => 'visible' })
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
beforeAll(async () => {
|
|
48
|
+
harness = await setupDom()
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
afterAll(() => harness.teardown())
|
|
52
|
+
|
|
53
|
+
beforeEach(() => {
|
|
54
|
+
probeMaxDurationMs = undefined
|
|
55
|
+
jest.useFakeTimers()
|
|
56
|
+
SpecMediaRecorder.instances = []
|
|
57
|
+
stream = new SpecStream()
|
|
58
|
+
;(globalThis as Record<string, unknown>).MediaRecorder = SpecMediaRecorder
|
|
59
|
+
;(navigator as unknown as Record<string, unknown>).mediaDevices = { getUserMedia: async () => stream }
|
|
60
|
+
URL.createObjectURL = () => 'blob:fake-recording'
|
|
61
|
+
URL.revokeObjectURL = () => {}
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
afterEach(() => {
|
|
65
|
+
jest.useRealTimers()
|
|
66
|
+
delete (globalThis as Record<string, unknown>).MediaRecorder
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
describe('a recorder that follows the MediaRecorder specification (stop is asynchronous)', () => {
|
|
70
|
+
it('stopping twice still ends in review with the audio', async () => {
|
|
71
|
+
await startAndRecord()
|
|
72
|
+
act(() => {
|
|
73
|
+
api.stop()
|
|
74
|
+
api.stop()
|
|
75
|
+
})
|
|
76
|
+
await flush()
|
|
77
|
+
expect(api.status).toBe('review')
|
|
78
|
+
expect(api.recorded?.file.size).toBe(4096)
|
|
79
|
+
expect(SpecMediaRecorder.instances[0]?.stopCalls).toBe(1)
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it('track.onended after the recorder stopped by itself still ends in review', async () => {
|
|
83
|
+
await startAndRecord()
|
|
84
|
+
SpecMediaRecorder.instances[0]?.autoStop()
|
|
85
|
+
act(() => stream.track.onended?.())
|
|
86
|
+
await flush()
|
|
87
|
+
expect(api.status).toBe('review')
|
|
88
|
+
expect(api.recorded?.file.size).toBe(4096)
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
it('the page becoming hidden right after Stop still ends in review', async () => {
|
|
92
|
+
await startAndRecord()
|
|
93
|
+
act(() => api.stop())
|
|
94
|
+
hidePage()
|
|
95
|
+
await flush()
|
|
96
|
+
expect(api.status).toBe('review')
|
|
97
|
+
expect(api.recorded?.file.size).toBe(4096)
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
it('the duration ceiling coinciding with Stop still ends in review', async () => {
|
|
101
|
+
probeMaxDurationMs = 3000
|
|
102
|
+
await startAndRecord()
|
|
103
|
+
act(() => api.stop())
|
|
104
|
+
await act(async () => {
|
|
105
|
+
jest.advanceTimersByTime(4000)
|
|
106
|
+
})
|
|
107
|
+
await flush()
|
|
108
|
+
expect(api.status).toBe('review')
|
|
109
|
+
})
|
|
110
|
+
})
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test'
|
|
2
|
+
|
|
3
|
+
import { DEFAULT_MAX_RECORDING_MILLISECONDS } from '../audioRecorderFormat'
|
|
4
|
+
import {
|
|
5
|
+
canStartRecording,
|
|
6
|
+
classifyCapture,
|
|
7
|
+
classifyMicrophoneError,
|
|
8
|
+
formatRecordingClock,
|
|
9
|
+
MINIMUM_RECORDING_MILLISECONDS,
|
|
10
|
+
reduceRecorderStatus,
|
|
11
|
+
resolveInterruptionAction,
|
|
12
|
+
resolveRecordingLimits,
|
|
13
|
+
type RecorderEvent,
|
|
14
|
+
type RecorderStatus,
|
|
15
|
+
} from './participantAudioRecorderState'
|
|
16
|
+
import { DEFAULT_MAX_RECORDING_BYTES } from './participantAudio.types'
|
|
17
|
+
|
|
18
|
+
const SIXTEEN_MIB = 16 * 1024 * 1024
|
|
19
|
+
|
|
20
|
+
describe('resolveRecordingLimits only lowers the defaults (contract 2)', () => {
|
|
21
|
+
it('uses the defaults when nothing is passed', () => {
|
|
22
|
+
expect(resolveRecordingLimits({})).toEqual({ maxDurationMs: DEFAULT_MAX_RECORDING_MILLISECONDS, maxSizeBytes: SIXTEEN_MIB })
|
|
23
|
+
expect(DEFAULT_MAX_RECORDING_BYTES).toBe(SIXTEEN_MIB)
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it('lowers the duration and the size', () => {
|
|
27
|
+
expect(resolveRecordingLimits({ options: { maxDurationMs: 60_000, maxSizeBytes: 1_000_000 } })).toEqual({
|
|
28
|
+
maxDurationMs: 60_000,
|
|
29
|
+
maxSizeBytes: 1_000_000,
|
|
30
|
+
})
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('cuts a larger duration or size back to the default', () => {
|
|
34
|
+
const limits = resolveRecordingLimits({ options: { maxDurationMs: 3_600_000, maxSizeBytes: 100 * 1024 * 1024 } })
|
|
35
|
+
expect(limits.maxDurationMs).toBe(DEFAULT_MAX_RECORDING_MILLISECONDS)
|
|
36
|
+
expect(limits.maxSizeBytes).toBe(SIXTEEN_MIB)
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it('takes the smallest of the policy, the channel and the passed value', () => {
|
|
40
|
+
expect(resolveRecordingLimits({ channelMaxBytes: 10 * 1024 * 1024 }).maxSizeBytes).toBe(10 * 1024 * 1024)
|
|
41
|
+
expect(resolveRecordingLimits({ channelMaxBytes: 25 * 1024 * 1024 }).maxSizeBytes).toBe(SIXTEEN_MIB)
|
|
42
|
+
expect(resolveRecordingLimits({ options: { maxSizeBytes: 5_000_000 }, channelMaxBytes: 10_000_000 }).maxSizeBytes).toBe(5_000_000)
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('ignores values that are not a positive number', () => {
|
|
46
|
+
for (const bad of [0, -5, Number.NaN]) {
|
|
47
|
+
expect(resolveRecordingLimits({ options: { maxDurationMs: bad, maxSizeBytes: bad } })).toEqual({
|
|
48
|
+
maxDurationMs: DEFAULT_MAX_RECORDING_MILLISECONDS,
|
|
49
|
+
maxSizeBytes: SIXTEEN_MIB,
|
|
50
|
+
})
|
|
51
|
+
}
|
|
52
|
+
})
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
describe('reduceRecorderStatus', () => {
|
|
56
|
+
const table: ReadonlyArray<readonly [RecorderStatus, RecorderEvent, RecorderStatus]> = [
|
|
57
|
+
['idle', 'request', 'requesting'],
|
|
58
|
+
['denied', 'request', 'requesting'],
|
|
59
|
+
['unsupported', 'request', 'requesting'],
|
|
60
|
+
['empty', 'request', 'requesting'],
|
|
61
|
+
['tooLarge', 'request', 'requesting'],
|
|
62
|
+
['requesting', 'granted', 'recording'],
|
|
63
|
+
['requesting', 'microphoneDenied', 'denied'],
|
|
64
|
+
['requesting', 'microphoneUnavailable', 'unsupported'],
|
|
65
|
+
['requesting', 'cancel', 'idle'],
|
|
66
|
+
['recording', 'capturedReview', 'review'],
|
|
67
|
+
['recording', 'capturedEmpty', 'empty'],
|
|
68
|
+
['recording', 'capturedTooLarge', 'tooLarge'],
|
|
69
|
+
['recording', 'cancel', 'idle'],
|
|
70
|
+
['review', 'cancel', 'idle'],
|
|
71
|
+
]
|
|
72
|
+
|
|
73
|
+
for (const [from, event, to] of table) {
|
|
74
|
+
it(`${from} + ${event} -> ${to}`, () => {
|
|
75
|
+
expect(reduceRecorderStatus(from, event)).toBe(to)
|
|
76
|
+
})
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
it('ignores events that do not apply to the current state', () => {
|
|
80
|
+
expect(reduceRecorderStatus('recording', 'request')).toBe('recording')
|
|
81
|
+
expect(reduceRecorderStatus('review', 'request')).toBe('review')
|
|
82
|
+
expect(reduceRecorderStatus('requesting', 'request')).toBe('requesting')
|
|
83
|
+
expect(reduceRecorderStatus('idle', 'granted')).toBe('idle')
|
|
84
|
+
expect(reduceRecorderStatus('review', 'capturedEmpty')).toBe('review')
|
|
85
|
+
})
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
describe('resolveInterruptionAction (contract 2.1)', () => {
|
|
89
|
+
it('stops a recording, pauses a review, cancels a pending request', () => {
|
|
90
|
+
expect(resolveInterruptionAction('recording')).toBe('stop')
|
|
91
|
+
expect(resolveInterruptionAction('review')).toBe('pause')
|
|
92
|
+
expect(resolveInterruptionAction('requesting')).toBe('cancel')
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
it('ignores the idle and error states', () => {
|
|
96
|
+
for (const status of ['idle', 'denied', 'unsupported', 'empty', 'tooLarge'] as const) {
|
|
97
|
+
expect(resolveInterruptionAction(status)).toBe('ignore')
|
|
98
|
+
}
|
|
99
|
+
})
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
describe('classifyCapture', () => {
|
|
103
|
+
const limits = { maxDurationMs: 300_000, maxSizeBytes: 1000 }
|
|
104
|
+
|
|
105
|
+
it('is empty without bytes or below the minimum duration', () => {
|
|
106
|
+
expect(classifyCapture({ sizeBytes: 0, durationMs: 5000 }, limits)).toBe('capturedEmpty')
|
|
107
|
+
expect(classifyCapture({ sizeBytes: 500, durationMs: 0 }, limits)).toBe('capturedEmpty')
|
|
108
|
+
expect(classifyCapture({ sizeBytes: 500, durationMs: MINIMUM_RECORDING_MILLISECONDS - 1 }, limits)).toBe('capturedEmpty')
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
it('is too large above the byte ceiling and fine at it', () => {
|
|
112
|
+
expect(classifyCapture({ sizeBytes: 1001, durationMs: 5000 }, limits)).toBe('capturedTooLarge')
|
|
113
|
+
expect(classifyCapture({ sizeBytes: 1000, durationMs: MINIMUM_RECORDING_MILLISECONDS }, limits)).toBe('capturedReview')
|
|
114
|
+
})
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
describe('classifyMicrophoneError', () => {
|
|
118
|
+
it('maps the permission errors to denied and the rest to unavailable', () => {
|
|
119
|
+
expect(classifyMicrophoneError({ name: 'NotAllowedError' })).toBe('microphoneDenied')
|
|
120
|
+
expect(classifyMicrophoneError({ name: 'SecurityError' })).toBe('microphoneDenied')
|
|
121
|
+
for (const name of ['NotFoundError', 'NotReadableError', 'OverconstrainedError', 'AbortError']) {
|
|
122
|
+
expect(classifyMicrophoneError({ name })).toBe('microphoneUnavailable')
|
|
123
|
+
}
|
|
124
|
+
expect(classifyMicrophoneError('boom')).toBe('microphoneUnavailable')
|
|
125
|
+
expect(classifyMicrophoneError(undefined)).toBe('microphoneUnavailable')
|
|
126
|
+
})
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
describe('formatRecordingClock', () => {
|
|
130
|
+
it('formats m:ss, flooring the seconds', () => {
|
|
131
|
+
expect(formatRecordingClock(0)).toBe('0:00')
|
|
132
|
+
expect(formatRecordingClock(12_999)).toBe('0:12')
|
|
133
|
+
expect(formatRecordingClock(65_000)).toBe('1:05')
|
|
134
|
+
expect(formatRecordingClock(300_000)).toBe('5:00')
|
|
135
|
+
expect(formatRecordingClock(-10)).toBe('0:00')
|
|
136
|
+
})
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
describe('canStartRecording', () => {
|
|
140
|
+
it('lets a tap start only from idle and the failure states', () => {
|
|
141
|
+
for (const status of ['idle', 'denied', 'unsupported', 'empty', 'tooLarge'] as const) expect(canStartRecording(status)).toBe(true)
|
|
142
|
+
for (const status of ['requesting', 'recording', 'review'] as const) expect(canStartRecording(status)).toBe(false)
|
|
143
|
+
})
|
|
144
|
+
})
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { DEFAULT_MAX_RECORDING_MILLISECONDS } from '../audioRecorderFormat'
|
|
2
|
+
import { DEFAULT_MAX_RECORDING_BYTES, type ParticipantAudioRecordingOptions } from './participantAudio.types'
|
|
3
|
+
|
|
4
|
+
export type RecorderStatus =
|
|
5
|
+
| 'idle'
|
|
6
|
+
| 'requesting'
|
|
7
|
+
| 'recording'
|
|
8
|
+
| 'review'
|
|
9
|
+
| 'denied'
|
|
10
|
+
| 'unsupported'
|
|
11
|
+
| 'empty'
|
|
12
|
+
| 'tooLarge'
|
|
13
|
+
|
|
14
|
+
export type RecorderEvent =
|
|
15
|
+
| 'request'
|
|
16
|
+
| 'granted'
|
|
17
|
+
| 'microphoneDenied'
|
|
18
|
+
| 'microphoneUnavailable'
|
|
19
|
+
| 'capturedReview'
|
|
20
|
+
| 'capturedEmpty'
|
|
21
|
+
| 'capturedTooLarge'
|
|
22
|
+
| 'cancel'
|
|
23
|
+
|
|
24
|
+
export type InterruptionAction = 'stop' | 'pause' | 'cancel' | 'ignore'
|
|
25
|
+
export type RecordingLimits = { readonly maxDurationMs: number; readonly maxSizeBytes: number }
|
|
26
|
+
export type CaptureMeasure = { readonly sizeBytes: number; readonly durationMs: number }
|
|
27
|
+
|
|
28
|
+
export const MINIMUM_RECORDING_MILLISECONDS = 500
|
|
29
|
+
export const MUTE_INTERRUPTION_DELAY_MS = 3000
|
|
30
|
+
|
|
31
|
+
const RETRYABLE: readonly RecorderStatus[] = ['idle', 'denied', 'unsupported', 'empty', 'tooLarge']
|
|
32
|
+
|
|
33
|
+
const TRANSITIONS: Readonly<Record<RecorderEvent, ReadonlyArray<readonly [readonly RecorderStatus[], RecorderStatus]>>> = {
|
|
34
|
+
request: [[RETRYABLE, 'requesting']],
|
|
35
|
+
granted: [[['requesting'], 'recording']],
|
|
36
|
+
microphoneDenied: [[['requesting'], 'denied']],
|
|
37
|
+
microphoneUnavailable: [[['requesting'], 'unsupported']],
|
|
38
|
+
capturedReview: [[['recording'], 'review']],
|
|
39
|
+
capturedEmpty: [[['recording'], 'empty']],
|
|
40
|
+
capturedTooLarge: [[['recording'], 'tooLarge']],
|
|
41
|
+
cancel: [[['requesting', 'recording', 'review'], 'idle']],
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function canStartRecording(status: RecorderStatus): boolean {
|
|
45
|
+
return RETRYABLE.includes(status)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function reduceRecorderStatus(status: RecorderStatus, event: RecorderEvent): RecorderStatus {
|
|
49
|
+
const match = TRANSITIONS[event].find(([from]) => from.includes(status))
|
|
50
|
+
return match ? match[1] : status
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function resolveInterruptionAction(status: RecorderStatus): InterruptionAction {
|
|
54
|
+
if (status === 'recording') return 'stop'
|
|
55
|
+
if (status === 'review') return 'pause'
|
|
56
|
+
if (status === 'requesting') return 'cancel'
|
|
57
|
+
return 'ignore'
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function lowerOnly(requested: number | undefined, ceiling: number): number {
|
|
61
|
+
if (requested === undefined || !Number.isFinite(requested) || requested <= 0) return ceiling
|
|
62
|
+
return Math.min(requested, ceiling)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export type ResolveRecordingLimitsParams = {
|
|
66
|
+
readonly options?: ParticipantAudioRecordingOptions
|
|
67
|
+
readonly channelMaxBytes?: number
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function resolveRecordingLimits({ options, channelMaxBytes }: ResolveRecordingLimitsParams): RecordingLimits {
|
|
71
|
+
const sizeCeiling = lowerOnly(channelMaxBytes, DEFAULT_MAX_RECORDING_BYTES)
|
|
72
|
+
return {
|
|
73
|
+
maxDurationMs: lowerOnly(options?.maxDurationMs, DEFAULT_MAX_RECORDING_MILLISECONDS),
|
|
74
|
+
maxSizeBytes: lowerOnly(options?.maxSizeBytes, sizeCeiling),
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function classifyCapture({ sizeBytes, durationMs }: CaptureMeasure, limits: RecordingLimits): RecorderEvent {
|
|
79
|
+
if (sizeBytes === 0 || durationMs < MINIMUM_RECORDING_MILLISECONDS) return 'capturedEmpty'
|
|
80
|
+
if (sizeBytes > limits.maxSizeBytes) return 'capturedTooLarge'
|
|
81
|
+
return 'capturedReview'
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function classifyMicrophoneError(error: unknown): 'microphoneDenied' | 'microphoneUnavailable' {
|
|
85
|
+
const name = typeof error === 'object' && error !== null && 'name' in error ? (error as { name: unknown }).name : undefined
|
|
86
|
+
return name === 'NotAllowedError' || name === 'SecurityError' ? 'microphoneDenied' : 'microphoneUnavailable'
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function formatRecordingClock(milliseconds: number): string {
|
|
90
|
+
const totalSeconds = Math.floor(Math.max(0, milliseconds) / 1000)
|
|
91
|
+
const seconds = String(totalSeconds % 60).padStart(2, '0')
|
|
92
|
+
return `${Math.floor(totalSeconds / 60)}:${seconds}`
|
|
93
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { renderToStaticMarkup } from 'react-dom/server'
|
|
2
|
+
|
|
3
|
+
import { describe, expect, it } from 'bun:test'
|
|
4
|
+
|
|
5
|
+
import { ParticipantAudioRecorderView, type ParticipantAudioRecorderViewProps } from './ParticipantAudioRecorderView'
|
|
6
|
+
import type { RecorderStatus } from './participantAudioRecorderState'
|
|
7
|
+
import { DEFAULT_PARTICIPANT_CONVERSATIONS_LABELS } from './participantLabels'
|
|
8
|
+
|
|
9
|
+
function render(overrides: Partial<ParticipantAudioRecorderViewProps> & { state: RecorderStatus }): string {
|
|
10
|
+
return renderToStaticMarkup(<ParticipantAudioRecorderView elapsedMs={12_000} {...overrides} />)
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
describe('idle and requesting', () => {
|
|
14
|
+
it('renders the microphone as a non-submitting primary button named by recordAudio', () => {
|
|
15
|
+
const markup = render({ state: 'idle' })
|
|
16
|
+
expect(markup).toContain('type="button"')
|
|
17
|
+
expect(markup).toContain('class="cv-p-button cv-p-button--primary cv-p-composer__record"')
|
|
18
|
+
expect(markup).toContain('aria-label="Record audio"')
|
|
19
|
+
expect(markup).not.toContain('type="submit"')
|
|
20
|
+
expect(markup).not.toContain('aria-busy')
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it('is busy and disabled while the permission is pending', () => {
|
|
24
|
+
const markup = render({ state: 'requesting' })
|
|
25
|
+
expect(markup).toContain('aria-busy="true"')
|
|
26
|
+
expect(markup).toContain('disabled=""')
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it('is disabled when the composer is', () => {
|
|
30
|
+
expect(render({ state: 'idle', disabled: true })).toContain('disabled=""')
|
|
31
|
+
})
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
describe('recording', () => {
|
|
35
|
+
it('shows the clock "m:ss of m:ss", discard and stop, and no aria-live', () => {
|
|
36
|
+
const markup = render({ state: 'recording' })
|
|
37
|
+
expect(markup).toContain('role="group"')
|
|
38
|
+
expect(markup).toContain('aria-label="Recording"')
|
|
39
|
+
expect(markup).toContain('role="timer"')
|
|
40
|
+
expect(markup).toContain('0:12 of 5:00')
|
|
41
|
+
expect(markup).toContain('cv-p-recorder__discard')
|
|
42
|
+
expect(markup).toContain('cv-p-recorder__stop')
|
|
43
|
+
expect(markup).not.toContain('aria-live')
|
|
44
|
+
expect(markup).not.toContain('cv-p-composer__record')
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('uses the lowered ceiling in the clock', () => {
|
|
48
|
+
expect(render({ state: 'recording', maxDurationMs: 90_000 })).toContain('0:12 of 1:30')
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('every button is type="button"', () => {
|
|
52
|
+
const buttons = render({ state: 'recording' }).match(/<button[^>]*>/g) ?? []
|
|
53
|
+
expect(buttons).toHaveLength(2)
|
|
54
|
+
for (const button of buttons) expect(button).toContain('type="button"')
|
|
55
|
+
})
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
describe('review', () => {
|
|
59
|
+
it('has the hint, the player over the object URL, discard and send', () => {
|
|
60
|
+
const markup = render({ state: 'review', review: { url: 'blob:abc' } })
|
|
61
|
+
expect(markup).toContain('cv-p-recorder--review')
|
|
62
|
+
expect(markup).toContain('<p class="cv-p-recorder__hint" aria-hidden="true">Listen before sending</p>')
|
|
63
|
+
expect(markup).toContain('<audio')
|
|
64
|
+
expect(markup).toContain('src="blob:abc"')
|
|
65
|
+
expect(markup).toContain('cv-p-recorder__discard')
|
|
66
|
+
expect(markup).toContain('cv-p-recorder__send')
|
|
67
|
+
expect(markup).toContain('aria-label="Send audio"')
|
|
68
|
+
})
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
describe('failure states', () => {
|
|
72
|
+
const cases: ReadonlyArray<readonly [RecorderStatus, string]> = [
|
|
73
|
+
['denied', 'No permission to use the microphone.'],
|
|
74
|
+
['unsupported', 'This device cannot record audio.'],
|
|
75
|
+
['empty', 'Nothing was captured by the microphone.'],
|
|
76
|
+
['tooLarge', 'Audio too large to send.'],
|
|
77
|
+
]
|
|
78
|
+
for (const [state, text] of cases) {
|
|
79
|
+
it(`${state} keeps the microphone and announces the phrase as an alert`, () => {
|
|
80
|
+
const markup = render({ state })
|
|
81
|
+
expect(markup).toContain(`<p class="cv-p-composer__error" role="alert">${text}</p>`)
|
|
82
|
+
expect(markup).toContain('cv-p-composer__record')
|
|
83
|
+
})
|
|
84
|
+
}
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
describe('labels', () => {
|
|
88
|
+
it('uses the English defaults when the host map does not carry the new keys', () => {
|
|
89
|
+
const { recordAudio: _omitted, ...withoutAudio } = DEFAULT_PARTICIPANT_CONVERSATIONS_LABELS
|
|
90
|
+
expect(render({ state: 'idle', labels: withoutAudio })).toContain('aria-label="Record audio"')
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
it('takes the host translation when present', () => {
|
|
94
|
+
const labels = { ...DEFAULT_PARTICIPANT_CONVERSATIONS_LABELS, recordAudio: 'Gravar áudio', recordingElapsed: '{elapsed} de {limit}' }
|
|
95
|
+
expect(render({ state: 'idle', labels })).toContain('aria-label="Gravar áudio"')
|
|
96
|
+
expect(render({ state: 'recording', labels })).toContain('0:12 de 5:00')
|
|
97
|
+
})
|
|
98
|
+
})
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { channelCapabilityFor } from '../channelCapability'
|
|
2
|
+
import { resolveRecordingFormat, type RecordingFormat } from '../audioRecorderFormat'
|
|
3
|
+
import type { ConversationChannel } from '../conversationChannel'
|
|
4
|
+
import type { ParticipantAudioRecordingOptions } from './participantAudio.types'
|
|
5
|
+
import { matchesAcceptedType } from './participantFileValidation'
|
|
6
|
+
|
|
7
|
+
export type ComposerAudioSettings = {
|
|
8
|
+
readonly audioRecording: ParticipantAudioRecordingOptions
|
|
9
|
+
readonly channel: ConversationChannel
|
|
10
|
+
readonly onSendAudio: (file: File) => void
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export type ResolveComposerAudioParams = {
|
|
14
|
+
readonly audioRecording?: ParticipantAudioRecordingOptions
|
|
15
|
+
readonly onSendAudio?: (file: File) => void
|
|
16
|
+
readonly channel?: ConversationChannel
|
|
17
|
+
readonly acceptedTypes?: readonly string[]
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Feature detection only: on a server there is no MediaRecorder, so it answers false. */
|
|
21
|
+
function resolveBrowserRecordingFormat(): RecordingFormat | undefined {
|
|
22
|
+
if (typeof navigator === 'undefined' || !navigator.mediaDevices?.getUserMedia) return undefined
|
|
23
|
+
return resolveRecordingFormat()
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** The host opts in through the prop; the channel and the browser can only switch the microphone off. */
|
|
27
|
+
export function resolveComposerAudio(params: ResolveComposerAudioParams): ComposerAudioSettings | undefined {
|
|
28
|
+
const { audioRecording, onSendAudio, channel = 'app', acceptedTypes } = params
|
|
29
|
+
if (!audioRecording || !onSendAudio) return undefined
|
|
30
|
+
const capability = channelCapabilityFor(channel)
|
|
31
|
+
if (!capability.audio.records || !capability.attachments.accepted) return undefined
|
|
32
|
+
const format = resolveBrowserRecordingFormat()
|
|
33
|
+
if (!format) return undefined
|
|
34
|
+
if (acceptedTypes && !matchesAcceptedType(format.uploadMimeType, acceptedTypes)) return undefined
|
|
35
|
+
return { audioRecording, channel, onSendAudio }
|
|
36
|
+
}
|