@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.
Files changed (129) hide show
  1. package/README.md +106 -0
  2. package/dist/{ConversationSimulatorPanel-q-wVgBCN.d.ts → ConversationSimulatorPanel-P6eqTSAO.d.ts} +1 -16
  3. package/dist/{chunk-HMCOTZAX.js → chunk-NSOAUYNE.js} +33 -28
  4. package/dist/{chunk-O6457XHK.js → chunk-WCZNIJ3M.js} +16 -1
  5. package/dist/{chunk-L3OFAQPS.js → chunk-Y326LLEP.js} +4 -14
  6. package/dist/chunk-ZSOD5AQT.js +19 -0
  7. package/dist/flows/index.js +4 -2
  8. package/dist/index.d.ts +10 -3
  9. package/dist/index.js +8 -6
  10. package/dist/participant/index.d.ts +54 -3
  11. package/dist/participant/index.js +1722 -418
  12. package/dist/preview/index.d.ts +2 -2
  13. package/dist/preview/index.js +2 -2
  14. package/dist/styles.css +281 -23
  15. package/package.json +3 -2
  16. package/src/AudioRecorderButton.tsx +4 -28
  17. package/src/Tooltip.tsx +5 -12
  18. package/src/TooltipDom.test.tsx +78 -0
  19. package/src/audioRecorderFormat.ts +27 -0
  20. package/src/domHarness.test-helper.ts +53 -0
  21. package/src/participant/ConversationThread.tsx +4 -0
  22. package/src/participant/ParticipantAttachmentItem.tsx +7 -10
  23. package/src/participant/ParticipantAudioAttachment.tsx +34 -0
  24. package/src/participant/ParticipantAudioPlayer.tsx +68 -0
  25. package/src/participant/ParticipantAudioPlayerControls.tsx +68 -0
  26. package/src/participant/ParticipantAudioRecorderView.tsx +55 -0
  27. package/src/participant/ParticipantAudioTranscription.tsx +90 -0
  28. package/src/participant/ParticipantBackButton.tsx +1 -1
  29. package/src/participant/ParticipantBubbleBody.tsx +54 -0
  30. package/src/participant/ParticipantBubbleStatus.tsx +22 -28
  31. package/src/participant/ParticipantChannelBadges.tsx +10 -2
  32. package/src/participant/ParticipantComposer.tsx +51 -16
  33. package/src/participant/ParticipantComposerAudio.tsx +95 -0
  34. package/src/participant/ParticipantComposerControls.tsx +2 -0
  35. package/src/participant/ParticipantConversations.tsx +3 -0
  36. package/src/participant/ParticipantCopyableToken.tsx +1 -0
  37. package/src/participant/ParticipantFailedMenu.tsx +40 -0
  38. package/src/participant/ParticipantFailedMenuList.tsx +41 -0
  39. package/src/participant/ParticipantMessageBubble.test.tsx +6 -6
  40. package/src/participant/ParticipantMessageBubble.tsx +20 -52
  41. package/src/participant/ParticipantMessageText.test.tsx +1 -1
  42. package/src/participant/ParticipantMessageText.tsx +4 -2
  43. package/src/participant/ParticipantProtocolBadge.tsx +1 -0
  44. package/src/participant/ParticipantRecorderParts.tsx +118 -0
  45. package/src/participant/ParticipantScrollToLatest.tsx +1 -1
  46. package/src/participant/ParticipantThread.tsx +30 -12
  47. package/src/participant/ParticipantThreadHeader.test.tsx +1 -1
  48. package/src/participant/ParticipantThreadScreen.tsx +1 -0
  49. package/src/participant/failedMenuKeyboard.ts +29 -0
  50. package/src/participant/index.ts +3 -0
  51. package/src/participant/participantAudio.types.ts +10 -0
  52. package/src/participant/participantAudioCapture.test.ts +15 -0
  53. package/src/participant/participantAudioCapture.ts +85 -0
  54. package/src/participant/participantAudioCoordinator.ts +13 -0
  55. package/src/participant/participantAudioFormat.test.ts +93 -0
  56. package/src/participant/participantAudioFormat.ts +72 -0
  57. package/src/participant/participantAudioGolden.test.tsx +168 -0
  58. package/src/participant/participantAudioLabels.test.ts +22 -0
  59. package/src/participant/participantAudioLabels.ts +76 -0
  60. package/src/participant/participantAudioPlayerControlsDom.test.tsx +123 -0
  61. package/src/participant/participantAudioPlayerCss.test.ts +63 -0
  62. package/src/participant/participantAudioPlayerDom.test.tsx +154 -0
  63. package/src/participant/participantAudioPlayerDomKit.test-helper.tsx +84 -0
  64. package/src/participant/participantAudioPlayerState.ts +35 -0
  65. package/src/participant/participantAudioRecorderDom.test.tsx +127 -0
  66. package/src/participant/participantAudioRecorderInterruptionDom.test.tsx +135 -0
  67. package/src/participant/participantAudioRecorderSpecDom.test.tsx +110 -0
  68. package/src/participant/participantAudioRecorderState.test.ts +144 -0
  69. package/src/participant/participantAudioRecorderState.ts +93 -0
  70. package/src/participant/participantAudioRecorderView.test.tsx +98 -0
  71. package/src/participant/participantAudioSupport.ts +36 -0
  72. package/src/participant/participantAudioTranscriptionDom.test.tsx +198 -0
  73. package/src/participant/participantComposerAudioDom.test.tsx +185 -0
  74. package/src/participant/participantComposerAudioGolden.test.tsx +68 -0
  75. package/src/participant/participantComposerAudioSsr.test.tsx +74 -0
  76. package/src/participant/participantFailedMenuDom.test.tsx +154 -0
  77. package/src/participant/participantFailedMenuState.test.ts +61 -0
  78. package/src/participant/participantFailedMenuState.ts +57 -0
  79. package/src/participant/participantFailedMessage.test.tsx +83 -0
  80. package/src/participant/participantFileValidation.ts +1 -1
  81. package/src/participant/participantGolden.test.tsx +2 -0
  82. package/src/participant/participantGoldenScenarios.test-helper.tsx +14 -1
  83. package/src/participant/participantLabels.ts +10 -1
  84. package/src/participant/participantMediaFakes.test-helper.ts +82 -0
  85. package/src/participant/participantNoMicrophoneDom.test.tsx +44 -0
  86. package/src/participant/participantPublicTypes.test.ts +11 -0
  87. package/src/participant/participantRecorderDomKit.test-helper.tsx +131 -0
  88. package/src/participant/participantRecorderFakes.test-helper.ts +47 -0
  89. package/src/participant/participantSpecRecorderFake.test-helper.ts +52 -0
  90. package/src/participant/participantThemeContrast.test.ts +17 -0
  91. package/src/participant/participantTooltips.test.tsx +74 -0
  92. package/src/participant/test-fixtures/composer-audio-denied.golden.html +1 -0
  93. package/src/participant/test-fixtures/composer-audio-idle.golden.html +1 -0
  94. package/src/participant/test-fixtures/composer-audio-recording.golden.html +1 -0
  95. package/src/participant/test-fixtures/composer-audio-review.golden.html +1 -0
  96. package/src/participant/test-fixtures/thread-audio-native.golden.html +1 -0
  97. package/src/participant/test-fixtures/thread-audio-player.golden.html +1 -0
  98. package/src/participant/test-fixtures/thread-audio-transcription-empty.golden.html +1 -0
  99. package/src/participant/test-fixtures/thread-audio-transcription-pending.golden.html +1 -0
  100. package/src/participant/test-fixtures/thread-audio-transcription-ready.golden.html +1 -0
  101. package/src/participant/test-fixtures/thread-audio-transcription-too_long.golden.html +1 -0
  102. package/src/participant/test-fixtures/thread-avatars-initials.golden.html +1 -1
  103. package/src/participant/test-fixtures/thread-closed.golden.html +1 -1
  104. package/src/participant/test-fixtures/thread-composer-microphone-idle.golden.html +1 -0
  105. package/src/participant/test-fixtures/thread-default.golden.html +1 -1
  106. package/src/participant/test-fixtures/thread-empty-draft.golden.html +1 -0
  107. package/src/participant/test-fixtures/thread-error.golden.html +1 -1
  108. package/src/participant/test-fixtures/thread-loading.golden.html +1 -1
  109. package/src/participant/test-fixtures/thread-plain.golden.html +1 -1
  110. package/src/participant/test-fixtures/thread-tail-off.golden.html +1 -1
  111. package/src/participant/useFailedMenuEffects.ts +59 -0
  112. package/src/participant/useParticipantAudioMediaEvents.ts +46 -0
  113. package/src/participant/useParticipantAudioPlayer.ts +52 -0
  114. package/src/participant/useParticipantAudioPlayerActions.ts +47 -0
  115. package/src/participant/useParticipantAudioRecorder.ts +153 -0
  116. package/src/participant/useParticipantAudioRecorderCore.ts +55 -0
  117. package/src/participant/useParticipantAudioRecorderEffects.ts +70 -0
  118. package/src/participant/useParticipantAudioSourceRetry.ts +42 -0
  119. package/src/participant/useParticipantFailedMenu.ts +60 -0
  120. package/src/participant/useParticipantSendAudio.test.tsx +85 -0
  121. package/src/participant/useParticipantSendAudio.ts +28 -0
  122. package/src/participant/useParticipantThreadController.ts +20 -7
  123. package/src/participant/useStableAttachmentUrl.ts +71 -0
  124. package/src/participant/useSuppressTouchTooltips.ts +38 -0
  125. package/src/participant/useTranscriptionOverflow.ts +25 -0
  126. package/src/participant/useTranscriptionReadyAnnouncement.ts +24 -0
  127. package/src/styles.css +48 -5
  128. package/src/tooltipLayerRegistry.test.ts +56 -0
  129. 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
+ }