@adatechnology/conversations-ui 0.9.0 → 0.11.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 +107 -0
- package/dist/{ConversationSimulatorPanel-q-wVgBCN.d.ts → ConversationSimulatorPanel-P6eqTSAO.d.ts} +1 -16
- package/dist/{chunk-O6457XHK.js → chunk-WCZNIJ3M.js} +16 -1
- package/dist/{chunk-L3OFAQPS.js → chunk-Y326LLEP.js} +4 -14
- package/dist/index.d.ts +10 -3
- package/dist/index.js +3 -3
- package/dist/participant/index.d.ts +55 -3
- package/dist/participant/index.js +1369 -319
- package/dist/preview/index.d.ts +2 -2
- package/dist/preview/index.js +2 -2
- package/dist/styles.css +187 -0
- package/package.json +2 -2
- package/src/AudioRecorderButton.tsx +4 -28
- package/src/audioRecorderFormat.ts +27 -0
- package/src/participant/ConversationThread.tsx +7 -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/ParticipantComposer.tsx +50 -15
- package/src/participant/ParticipantComposerAudio.tsx +95 -0
- package/src/participant/ParticipantConversations.tsx +5 -0
- package/src/participant/ParticipantRecorderParts.tsx +118 -0
- package/src/participant/ParticipantThread.test.tsx +46 -0
- package/src/participant/ParticipantThread.tsx +17 -0
- package/src/participant/ParticipantThreadScreen.tsx +2 -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/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 +4 -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 +9 -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-composer-microphone-idle.golden.html +1 -0
- package/src/participant/test-fixtures/thread-empty-draft.golden.html +1 -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/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/useTranscriptionOverflow.ts +25 -0
- package/src/participant/useTranscriptionReadyAnnouncement.ts +24 -0
- package/src/styles.css +30 -0
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import { act, useState } from 'react'
|
|
2
|
+
|
|
3
|
+
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, jest, mock } from 'bun:test'
|
|
4
|
+
|
|
5
|
+
import { setupDom, type DomHarness } from '../domHarness.test-helper'
|
|
6
|
+
import { ConversationThread } from './ConversationThread'
|
|
7
|
+
import { ParticipantComposer, type ParticipantComposerProps } from './ParticipantComposer'
|
|
8
|
+
import { DEFAULT_PARTICIPANT_CONVERSATIONS_LABELS } from './participantLabels'
|
|
9
|
+
import { installFakeRecording, type FakeRecordingControls } from './participantRecorderFakes.test-helper'
|
|
10
|
+
|
|
11
|
+
let harness: DomHarness
|
|
12
|
+
let fakes: FakeRecordingControls
|
|
13
|
+
let container: HTMLElement
|
|
14
|
+
let sentAudio: File[]
|
|
15
|
+
let draftTouches: string[]
|
|
16
|
+
let captureEvents: boolean[]
|
|
17
|
+
|
|
18
|
+
beforeAll(async () => {
|
|
19
|
+
harness = await setupDom()
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
afterAll(() => harness.teardown())
|
|
23
|
+
|
|
24
|
+
beforeEach(() => {
|
|
25
|
+
jest.useFakeTimers()
|
|
26
|
+
fakes = installFakeRecording()
|
|
27
|
+
URL.createObjectURL = () => 'blob:fake-recording'
|
|
28
|
+
URL.revokeObjectURL = () => undefined
|
|
29
|
+
sentAudio = []
|
|
30
|
+
draftTouches = []
|
|
31
|
+
captureEvents = []
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
afterEach(() => {
|
|
35
|
+
jest.useRealTimers()
|
|
36
|
+
fakes.restore()
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
function mountComposer(overrides: Partial<ParticipantComposerProps> = {}): void {
|
|
40
|
+
const props: ParticipantComposerProps = {
|
|
41
|
+
value: '',
|
|
42
|
+
onChange: () => draftTouches.push('onChange'),
|
|
43
|
+
files: [],
|
|
44
|
+
onFilesChange: () => draftTouches.push('onFilesChange'),
|
|
45
|
+
onSend: () => draftTouches.push('onSend'),
|
|
46
|
+
labels: DEFAULT_PARTICIPANT_CONVERSATIONS_LABELS,
|
|
47
|
+
audioRecording: { onCaptureChange: (isCapturing) => captureEvents.push(isCapturing) },
|
|
48
|
+
onSendAudio: (file) => sentAudio.push(file),
|
|
49
|
+
...overrides,
|
|
50
|
+
}
|
|
51
|
+
container = harness.mount(<ParticipantComposer {...props} />).container
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function find(selector: string): HTMLButtonElement {
|
|
55
|
+
const element = container.querySelector<HTMLButtonElement>(selector)
|
|
56
|
+
if (!element) throw new Error(`missing ${selector}`)
|
|
57
|
+
return element
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function press(selector: string): Promise<void> {
|
|
61
|
+
await act(async () => find(selector).click())
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async function recordFor(milliseconds: number): Promise<void> {
|
|
65
|
+
await press('.cv-p-composer__record')
|
|
66
|
+
await act(async () => jest.advanceTimersByTime(milliseconds))
|
|
67
|
+
await press('.cv-p-recorder__stop')
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
describe('record, review and send from the composer', () => {
|
|
71
|
+
it('sends exactly one audio File and never touches the text draft or the attached files', async () => {
|
|
72
|
+
mountComposer()
|
|
73
|
+
await recordFor(1500)
|
|
74
|
+
expect(find('.cv-p-recorder--review')).toBeTruthy()
|
|
75
|
+
await press('.cv-p-recorder__send')
|
|
76
|
+
|
|
77
|
+
expect(sentAudio).toHaveLength(1)
|
|
78
|
+
expect(sentAudio[0]?.name).toMatch(/^audio-\d{8}-\d{6}\.ogg$/)
|
|
79
|
+
expect(sentAudio[0]?.type).toBe('audio/ogg')
|
|
80
|
+
expect(draftTouches).toEqual([])
|
|
81
|
+
expect(container.querySelector('.cv-p-composer__record')).not.toBeNull()
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it('shows the recorder row in place of the field while recording, with the error above the row when idle', async () => {
|
|
85
|
+
mountComposer()
|
|
86
|
+
await press('.cv-p-composer__record')
|
|
87
|
+
expect(container.querySelector('.cv-p-composer__input')).toBeNull()
|
|
88
|
+
expect(find('.cv-p-recorder[role="group"]')).toBeTruthy()
|
|
89
|
+
expect(container.querySelector('[role="timer"]')?.textContent).toBe('0:00 of 5:00')
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
// Guard held here: recorder.confirm() returns the File once, because statusRef leaves 'review' synchronously
|
|
93
|
+
// (the Set<File> of useParticipantSendAudio is pinned by its own test file).
|
|
94
|
+
it('turns two synchronous clicks on "Send audio" into one message', async () => {
|
|
95
|
+
mountComposer()
|
|
96
|
+
await recordFor(1500)
|
|
97
|
+
const sendButton = find('.cv-p-recorder__send')
|
|
98
|
+
await act(async () => {
|
|
99
|
+
sendButton.click()
|
|
100
|
+
sendButton.click()
|
|
101
|
+
})
|
|
102
|
+
expect(sentAudio).toHaveLength(1)
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
it('refuses an audio over the size ceiling before sending, with the message above the microphone', async () => {
|
|
106
|
+
mountComposer({ audioRecording: { maxSizeBytes: 1000 } })
|
|
107
|
+
await recordFor(1500)
|
|
108
|
+
expect(sentAudio).toHaveLength(0)
|
|
109
|
+
expect(container.querySelector('[role="alert"]')?.textContent).toBe(DEFAULT_PARTICIPANT_CONVERSATIONS_LABELS.audioTooLarge)
|
|
110
|
+
expect(container.querySelector('.cv-p-composer__record')).not.toBeNull()
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
it('refuses an empty capture before sending', async () => {
|
|
114
|
+
fakes.chunkBytes = 0
|
|
115
|
+
mountComposer()
|
|
116
|
+
await recordFor(1500)
|
|
117
|
+
expect(sentAudio).toHaveLength(0)
|
|
118
|
+
expect(container.querySelector('[role="alert"]')?.textContent).toBe(DEFAULT_PARTICIPANT_CONVERSATIONS_LABELS.audioEmpty)
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
it('keeps the microphone disabled together with the composer', async () => {
|
|
122
|
+
mountComposer({ disabled: true })
|
|
123
|
+
expect(find('.cv-p-composer__record').disabled).toBe(true)
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
it('disables "Send audio" in review when the conversation becomes disabled, and still allows discarding', async () => {
|
|
127
|
+
let setDisabled: (next: boolean) => void = () => undefined
|
|
128
|
+
function Wrapper() {
|
|
129
|
+
const [disabled, update] = useState(false)
|
|
130
|
+
setDisabled = update
|
|
131
|
+
return (
|
|
132
|
+
<ParticipantComposer
|
|
133
|
+
value=""
|
|
134
|
+
onChange={() => undefined}
|
|
135
|
+
files={[]}
|
|
136
|
+
onFilesChange={() => undefined}
|
|
137
|
+
onSend={() => undefined}
|
|
138
|
+
labels={DEFAULT_PARTICIPANT_CONVERSATIONS_LABELS}
|
|
139
|
+
audioRecording={{}}
|
|
140
|
+
onSendAudio={(file) => sentAudio.push(file)}
|
|
141
|
+
disabled={disabled}
|
|
142
|
+
/>
|
|
143
|
+
)
|
|
144
|
+
}
|
|
145
|
+
container = harness.mount(<Wrapper />).container
|
|
146
|
+
await recordFor(1500)
|
|
147
|
+
expect(find('.cv-p-recorder__send').disabled).toBe(false)
|
|
148
|
+
act(() => setDisabled(true))
|
|
149
|
+
expect(find('.cv-p-recorder__send').disabled).toBe(true)
|
|
150
|
+
expect(find('.cv-p-recorder__discard').disabled).toBe(false)
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
it('reports the capture to the host from the request until the audio is sent', async () => {
|
|
154
|
+
mountComposer()
|
|
155
|
+
await press('.cv-p-composer__record')
|
|
156
|
+
expect(captureEvents).toEqual([true])
|
|
157
|
+
await act(async () => jest.advanceTimersByTime(1500))
|
|
158
|
+
await press('.cv-p-recorder__stop')
|
|
159
|
+
expect(captureEvents).toEqual([true])
|
|
160
|
+
await press('.cv-p-recorder__send')
|
|
161
|
+
expect(captureEvents).toEqual([true, false])
|
|
162
|
+
})
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
describe('the whole thread: the audio leaves as its own message through the attachment path', () => {
|
|
166
|
+
// Same guard as above (statusRef of confirm), now observed end to end at api.sendMessage.
|
|
167
|
+
it('calls api.sendMessage once with one File and no text, even on two synchronous clicks', async () => {
|
|
168
|
+
const sendMessage = mock(async () => ({ outcome: 'queued' as const }))
|
|
169
|
+
const api = { fetchMessages: async () => [], sendMessage, resolveAttachmentUrl: async () => 'https://files.example/a' }
|
|
170
|
+
container = harness.mount(
|
|
171
|
+
<ConversationThread api={api} subject={{ subjectType: 'case', subjectId: '7' }} title="Case 7" audioRecording={{}} />,
|
|
172
|
+
).container
|
|
173
|
+
await recordFor(1500)
|
|
174
|
+
const sendButton = find('.cv-p-recorder__send')
|
|
175
|
+
await act(async () => {
|
|
176
|
+
sendButton.click()
|
|
177
|
+
sendButton.click()
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
expect(sendMessage).toHaveBeenCalledTimes(1)
|
|
181
|
+
const [request] = sendMessage.mock.calls[0] as unknown as [{ text?: string; files?: File[] }]
|
|
182
|
+
expect(request.text).toBeUndefined()
|
|
183
|
+
expect(request.files).toHaveLength(1)
|
|
184
|
+
})
|
|
185
|
+
})
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
|
|
4
|
+
import { afterEach, beforeAll, describe, expect, it } from 'bun:test'
|
|
5
|
+
import type { ComponentType } from 'react'
|
|
6
|
+
import { renderToStaticMarkup } from 'react-dom/server'
|
|
7
|
+
|
|
8
|
+
import { ParticipantAudioRecorderView } from './ParticipantAudioRecorderView'
|
|
9
|
+
import { ParticipantThread } from './ParticipantThread'
|
|
10
|
+
import { buildThreadProps } from './participantGoldenScenarios.test-helper'
|
|
11
|
+
import { DEFAULT_PARTICIPANT_CONVERSATIONS_LABELS } from './participantLabels'
|
|
12
|
+
import { installFakeRecording } from './participantRecorderFakes.test-helper'
|
|
13
|
+
|
|
14
|
+
function readGolden(name: string): string {
|
|
15
|
+
return readFileSync(join(import.meta.dir, 'test-fixtures', `${name}.golden.html`), 'utf8')
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const Thread = ParticipantThread as unknown as ComponentType<Record<string, unknown>>
|
|
19
|
+
|
|
20
|
+
function renderThreadWithMicrophone(): string {
|
|
21
|
+
const props = {
|
|
22
|
+
...buildThreadProps(DEFAULT_PARTICIPANT_CONVERSATIONS_LABELS),
|
|
23
|
+
draft: { value: '', onChange: () => undefined, files: [], onFilesChange: () => undefined },
|
|
24
|
+
audioRecording: {},
|
|
25
|
+
onSendAudio: () => undefined,
|
|
26
|
+
}
|
|
27
|
+
return renderToStaticMarkup(<Thread {...props} />)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
describe('the real composer with the microphone (contract 2.2), not only the pure view', () => {
|
|
31
|
+
let restore: (() => void) | undefined
|
|
32
|
+
|
|
33
|
+
beforeAll(() => {
|
|
34
|
+
process.env.TZ = 'UTC'
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
afterEach(() => restore?.())
|
|
38
|
+
|
|
39
|
+
it('thread-composer-microphone-idle is byte-identical to its golden', () => {
|
|
40
|
+
restore = installFakeRecording().restore
|
|
41
|
+
const markup = renderThreadWithMicrophone()
|
|
42
|
+
expect(markup).toBe(readGolden('thread-composer-microphone-idle'))
|
|
43
|
+
})
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
describe('composer-audio-*: the pure recorder view (contract 2.2-2.3)', () => {
|
|
47
|
+
for (const state of ['idle', 'recording', 'review', 'denied'] as const) {
|
|
48
|
+
it(`composer-audio-${state} is byte-identical to its golden`, () => {
|
|
49
|
+
const markup = renderToStaticMarkup(
|
|
50
|
+
<ParticipantAudioRecorderView state={state} elapsedMs={12_000} labels={DEFAULT_PARTICIPANT_CONVERSATIONS_LABELS} />,
|
|
51
|
+
)
|
|
52
|
+
expect(markup).toBe(readGolden(`composer-audio-${state}`))
|
|
53
|
+
})
|
|
54
|
+
}
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
describe('the microphone in the composer (contract 2.2)', () => {
|
|
58
|
+
let restore: (() => void) | undefined
|
|
59
|
+
|
|
60
|
+
afterEach(() => restore?.())
|
|
61
|
+
|
|
62
|
+
it('takes the place of send only with the prop, an empty field and a recording browser', () => {
|
|
63
|
+
restore = installFakeRecording().restore
|
|
64
|
+
const withProp = renderThreadWithMicrophone()
|
|
65
|
+
expect(withProp).toContain('cv-p-composer__record')
|
|
66
|
+
expect(withProp).not.toContain('cv-p-composer__send')
|
|
67
|
+
})
|
|
68
|
+
})
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it } from 'bun:test'
|
|
2
|
+
import { renderToStaticMarkup } from 'react-dom/server'
|
|
3
|
+
|
|
4
|
+
import { ParticipantComposer, type ParticipantComposerProps } from './ParticipantComposer'
|
|
5
|
+
import { DEFAULT_PARTICIPANT_CONVERSATIONS_LABELS } from './participantLabels'
|
|
6
|
+
import { installFakeRecording, type FakeRecordingControls } from './participantRecorderFakes.test-helper'
|
|
7
|
+
|
|
8
|
+
const BASE: ParticipantComposerProps = {
|
|
9
|
+
value: '',
|
|
10
|
+
onChange: () => undefined,
|
|
11
|
+
files: [],
|
|
12
|
+
onFilesChange: () => undefined,
|
|
13
|
+
onSend: () => undefined,
|
|
14
|
+
labels: DEFAULT_PARTICIPANT_CONVERSATIONS_LABELS,
|
|
15
|
+
audioRecording: {},
|
|
16
|
+
onSendAudio: () => undefined,
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function render(overrides: Partial<ParticipantComposerProps> = {}): string {
|
|
20
|
+
return renderToStaticMarkup(<ParticipantComposer {...BASE} {...overrides} />)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
describe('the microphone in the composer (contract 2.2)', () => {
|
|
24
|
+
let environment: FakeRecordingControls | undefined
|
|
25
|
+
|
|
26
|
+
afterEach(() => {
|
|
27
|
+
environment?.restore()
|
|
28
|
+
environment = undefined
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it('takes the place of send with the prop, an empty field and a recording browser', () => {
|
|
32
|
+
environment = installFakeRecording()
|
|
33
|
+
const markup = render()
|
|
34
|
+
expect(markup).toContain('type="button" class="cv-p-button cv-p-button--primary cv-p-composer__record"')
|
|
35
|
+
expect(markup).not.toContain('cv-p-composer__send')
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('never appears without the prop', () => {
|
|
39
|
+
environment = installFakeRecording()
|
|
40
|
+
const markup = render({ audioRecording: undefined })
|
|
41
|
+
expect(markup).not.toContain('cv-p-composer__record')
|
|
42
|
+
expect(markup).toContain('cv-p-composer__send')
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('gives way to send when there is text or an attached file', () => {
|
|
46
|
+
environment = installFakeRecording()
|
|
47
|
+
for (const overrides of [{ value: 'oi' }, { files: [new File(['x'], 'a.txt')] }]) {
|
|
48
|
+
const markup = render(overrides)
|
|
49
|
+
expect(markup).not.toContain('cv-p-composer__record')
|
|
50
|
+
expect(markup).toContain('cv-p-composer__send')
|
|
51
|
+
}
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it('stays off when the browser cannot record', () => {
|
|
55
|
+
expect(render()).not.toContain('cv-p-composer__record')
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('stays off on the portal channel: the channel only switches the microphone off', () => {
|
|
59
|
+
environment = installFakeRecording()
|
|
60
|
+
expect(render({ channel: 'portal' })).not.toContain('cv-p-composer__record')
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('stays off when the host accepts no audio type or gives no way to send it', () => {
|
|
64
|
+
environment = installFakeRecording()
|
|
65
|
+
expect(render({ acceptedTypes: ['image/*'] })).not.toContain('cv-p-composer__record')
|
|
66
|
+
expect(render({ acceptedTypes: ['audio/*'] })).toContain('cv-p-composer__record')
|
|
67
|
+
expect(render({ onSendAudio: undefined })).not.toContain('cv-p-composer__record')
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it('is disabled together with the composer', () => {
|
|
71
|
+
environment = installFakeRecording()
|
|
72
|
+
expect(render({ disabled: true })).toMatch(/cv-p-composer__record"[^>]*disabled=""/)
|
|
73
|
+
})
|
|
74
|
+
})
|
|
@@ -15,7 +15,7 @@ export type ValidateParticipantFilesResult = {
|
|
|
15
15
|
readonly rejection?: ParticipantFileRejection
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
-
function matchesAcceptedType(mimeType: string, acceptedTypes: readonly string[]): boolean {
|
|
18
|
+
export function matchesAcceptedType(mimeType: string, acceptedTypes: readonly string[]): boolean {
|
|
19
19
|
return acceptedTypes.some((accepted) =>
|
|
20
20
|
accepted.endsWith('/*') ? mimeType.startsWith(accepted.slice(0, -1)) : mimeType === accepted,
|
|
21
21
|
)
|
|
@@ -20,7 +20,17 @@ const ITEMS = [
|
|
|
20
20
|
{ kind: 'pending', origin: 'local', displayState: 'failed', pending: { clientMessageId: 'c', subject: { subjectType: 'invoice', subjectId: '1' }, text: 'W', createdAt: '2026-10-02T12:06:00.000Z', state: 'failed' } },
|
|
21
21
|
]
|
|
22
22
|
|
|
23
|
-
|
|
23
|
+
export const EMPTY_DRAFT = { value: '', onChange: NOOP, files: [], onFilesChange: NOOP }
|
|
24
|
+
|
|
25
|
+
const AUDIO_NATIVE_ITEMS = [
|
|
26
|
+
{ kind: 'server', message: buildMessage({ id: 'au', direction: 'inbound', text: '', attachments: [{ id: 'att-1', kind: 'audio', filename: 'voice.webm', mimeType: 'audio/webm', sizeBytes: 2048 }], createdAt: '2026-10-01T12:00:00.000Z' }) },
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
const AUDIO_PLAYER_ITEMS = [
|
|
30
|
+
{ kind: 'server', message: buildMessage({ id: 'au', direction: 'inbound', text: '', attachments: [{ id: 'att-1', kind: 'audio', filename: 'voice.webm', mimeType: 'audio/webm', sizeBytes: 2048, durationMs: 65_000 }], createdAt: '2026-10-01T12:00:00.000Z' }) },
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
export function buildThreadProps(labels: unknown): Record<string, unknown> {
|
|
24
34
|
return {
|
|
25
35
|
conversation: buildConversation({ subjectId: '1', subjectLabel: 'Invoice 4521', protocol: 'P-1', channels: ['app', 'whatsapp'] }),
|
|
26
36
|
items: ITEMS, hasMore: true, onLoadOlder: NOOP, labels, resolveAttachmentUrl: async () => 'x',
|
|
@@ -40,6 +50,9 @@ const THREAD_VARIANTS: Record<string, Record<string, unknown>> = {
|
|
|
40
50
|
'thread-error': { status: 'error', items: [] },
|
|
41
51
|
'thread-loading': { status: 'loading', items: [] },
|
|
42
52
|
'thread-closed': { conversation: buildConversation({ subjectId: '2', status: 'closed' }) },
|
|
53
|
+
'thread-empty-draft': { draft: EMPTY_DRAFT },
|
|
54
|
+
'thread-audio-native': { items: AUDIO_NATIVE_ITEMS },
|
|
55
|
+
'thread-audio-player': { items: AUDIO_PLAYER_ITEMS },
|
|
43
56
|
}
|
|
44
57
|
|
|
45
58
|
export function renderGoldenScenarios({ ParticipantThread, ParticipantConversations, labels }: GoldenComponents): Record<string, string> {
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { DEFAULT_PARTICIPANT_AUDIO_LABELS, type ParticipantAudioLabels } from './participantAudioLabels'
|
|
2
|
+
|
|
1
3
|
export const DEFAULT_MESSAGE_OPTIONS_LABEL = 'Message options'
|
|
2
4
|
|
|
3
5
|
export type ParticipantConversationsLabels = {
|
|
@@ -65,7 +67,7 @@ export type ParticipantConversationsLabels = {
|
|
|
65
67
|
/** Prefix of the accessible name of the copy buttons: "Copy 529.982.247-25". */
|
|
66
68
|
readonly copyValue: string
|
|
67
69
|
readonly valueCopied: string
|
|
68
|
-
}
|
|
70
|
+
} & Readonly<Partial<ParticipantAudioLabels>>
|
|
69
71
|
|
|
70
72
|
export const DEFAULT_PARTICIPANT_CONVERSATIONS_LABELS: ParticipantConversationsLabels = {
|
|
71
73
|
inboxTitle: 'Conversations',
|
|
@@ -125,6 +127,7 @@ export const DEFAULT_PARTICIPANT_CONVERSATIONS_LABELS: ParticipantConversationsL
|
|
|
125
127
|
externalLinkHint: 'opens in a new tab',
|
|
126
128
|
copyValue: 'Copy',
|
|
127
129
|
valueCopied: 'Copied',
|
|
130
|
+
...DEFAULT_PARTICIPANT_AUDIO_LABELS,
|
|
128
131
|
}
|
|
129
132
|
|
|
130
133
|
export function formatParticipantLabel(template: string, count: number): string {
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
type MediaState = {
|
|
2
|
+
paused: boolean
|
|
3
|
+
currentTime: number
|
|
4
|
+
duration: number
|
|
5
|
+
defaultPlaybackRate: number
|
|
6
|
+
playbackRate: number
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export type FakeMediaControls = {
|
|
10
|
+
/** Elements in the order `play()` was called on them. */
|
|
11
|
+
readonly playCalls: HTMLMediaElement[]
|
|
12
|
+
readonly pauseCalls: HTMLMediaElement[]
|
|
13
|
+
/** What the next `play()` promises: the iOS gesture refusal is a rejection. */
|
|
14
|
+
playOutcome: 'resolve' | 'reject'
|
|
15
|
+
readonly stateOf: (audio: HTMLMediaElement) => MediaState
|
|
16
|
+
readonly emit: (audio: HTMLMediaElement, type: string) => void
|
|
17
|
+
readonly restore: () => void
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const OVERRIDDEN = ['paused', 'currentTime', 'duration', 'defaultPlaybackRate', 'playbackRate', 'play', 'pause', 'load'] as const
|
|
21
|
+
|
|
22
|
+
/** Replaces the HTMLMediaElement members the player uses with an in-memory media element. */
|
|
23
|
+
export function installFakeMedia(): FakeMediaControls {
|
|
24
|
+
const prototype = HTMLMediaElement.prototype
|
|
25
|
+
const previous = OVERRIDDEN.map((name) => [name, Object.getOwnPropertyDescriptor(prototype, name)] as const)
|
|
26
|
+
const states = new WeakMap<object, MediaState>()
|
|
27
|
+
const stateOf = (audio: HTMLMediaElement): MediaState => {
|
|
28
|
+
const existing = states.get(audio)
|
|
29
|
+
if (existing) return existing
|
|
30
|
+
const created: MediaState = { paused: true, currentTime: 0, duration: Number.NaN, defaultPlaybackRate: 1, playbackRate: 1 }
|
|
31
|
+
states.set(audio, created)
|
|
32
|
+
return created
|
|
33
|
+
}
|
|
34
|
+
const emit = (audio: HTMLMediaElement, type: string): void => {
|
|
35
|
+
audio.dispatchEvent(new Event(type))
|
|
36
|
+
}
|
|
37
|
+
const controls: FakeMediaControls = {
|
|
38
|
+
playCalls: [],
|
|
39
|
+
pauseCalls: [],
|
|
40
|
+
playOutcome: 'resolve',
|
|
41
|
+
stateOf,
|
|
42
|
+
emit,
|
|
43
|
+
restore: () => {
|
|
44
|
+
for (const [name, descriptor] of previous) {
|
|
45
|
+
if (descriptor) Object.defineProperty(prototype, name, descriptor)
|
|
46
|
+
else delete (prototype as unknown as Record<string, unknown>)[name]
|
|
47
|
+
}
|
|
48
|
+
},
|
|
49
|
+
}
|
|
50
|
+
for (const name of ['paused', 'currentTime', 'duration', 'defaultPlaybackRate', 'playbackRate'] as const) {
|
|
51
|
+
Object.defineProperty(prototype, name, {
|
|
52
|
+
configurable: true,
|
|
53
|
+
get(this: HTMLMediaElement) {
|
|
54
|
+
return stateOf(this)[name]
|
|
55
|
+
},
|
|
56
|
+
set(this: HTMLMediaElement, value: never) {
|
|
57
|
+
;(stateOf(this) as Record<string, unknown>)[name] = value
|
|
58
|
+
},
|
|
59
|
+
})
|
|
60
|
+
}
|
|
61
|
+
Object.defineProperty(prototype, 'play', {
|
|
62
|
+
configurable: true,
|
|
63
|
+
value(this: HTMLMediaElement): Promise<void> {
|
|
64
|
+
controls.playCalls.push(this)
|
|
65
|
+
if (controls.playOutcome === 'reject') return Promise.reject(new Error('NotAllowedError'))
|
|
66
|
+
stateOf(this).paused = false
|
|
67
|
+
emit(this, 'play')
|
|
68
|
+
return Promise.resolve()
|
|
69
|
+
},
|
|
70
|
+
})
|
|
71
|
+
Object.defineProperty(prototype, 'pause', {
|
|
72
|
+
configurable: true,
|
|
73
|
+
value(this: HTMLMediaElement): void {
|
|
74
|
+
if (stateOf(this).paused) return
|
|
75
|
+
controls.pauseCalls.push(this)
|
|
76
|
+
stateOf(this).paused = true
|
|
77
|
+
emit(this, 'pause')
|
|
78
|
+
},
|
|
79
|
+
})
|
|
80
|
+
Object.defineProperty(prototype, 'load', { configurable: true, value: () => undefined })
|
|
81
|
+
return controls
|
|
82
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { ComponentType } from 'react'
|
|
2
|
+
|
|
3
|
+
import { afterAll, beforeAll, describe, expect, it } from 'bun:test'
|
|
4
|
+
|
|
5
|
+
import { setupDom, type DomHarness } from '../domHarness.test-helper'
|
|
6
|
+
import { buildThreadProps } from './participantGoldenScenarios.test-helper'
|
|
7
|
+
import { DEFAULT_PARTICIPANT_CONVERSATIONS_LABELS } from './participantLabels'
|
|
8
|
+
import { ParticipantThread } from './ParticipantThread'
|
|
9
|
+
|
|
10
|
+
const UntypedThread = ParticipantThread as unknown as ComponentType<Record<string, unknown>>
|
|
11
|
+
|
|
12
|
+
describe('empty composer in a recording browser without the audioRecording prop (contract 2.4 item 3)', () => {
|
|
13
|
+
let harness: DomHarness
|
|
14
|
+
|
|
15
|
+
beforeAll(async () => {
|
|
16
|
+
harness = await setupDom()
|
|
17
|
+
const globals = globalThis as Record<string, unknown>
|
|
18
|
+
globals.MediaRecorder = class FakeMediaRecorder {
|
|
19
|
+
static isTypeSupported(): boolean {
|
|
20
|
+
return true
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
;(navigator as unknown as Record<string, unknown>).mediaDevices = { getUserMedia: async () => ({ getTracks: () => [] }) }
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
afterAll(() => {
|
|
27
|
+
delete (globalThis as Record<string, unknown>).MediaRecorder
|
|
28
|
+
harness.teardown()
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it('renders no microphone button even though MediaRecorder and getUserMedia exist', () => {
|
|
32
|
+
const props = {
|
|
33
|
+
...buildThreadProps(DEFAULT_PARTICIPANT_CONVERSATIONS_LABELS),
|
|
34
|
+
draft: { value: '', onChange: () => undefined, files: [], onFilesChange: () => undefined },
|
|
35
|
+
}
|
|
36
|
+
const { container } = harness.mount(<UntypedThread {...props} />)
|
|
37
|
+
|
|
38
|
+
expect(typeof (globalThis as Record<string, unknown>).MediaRecorder).toBe('function')
|
|
39
|
+
expect(container.querySelector('.cv-p-composer__send') !== null).toBe(true)
|
|
40
|
+
expect(container.querySelector('.cv-p-composer__record') === null).toBe(true)
|
|
41
|
+
expect(container.querySelector('button[aria-label="Record audio"]') === null).toBe(true)
|
|
42
|
+
expect(Array.from(container.querySelectorAll('button')).some((button) => /record/i.test(button.getAttribute('aria-label') ?? ''))).toBe(false)
|
|
43
|
+
})
|
|
44
|
+
})
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test'
|
|
2
|
+
|
|
3
|
+
import type { ParticipantAttachment, ParticipantTranscription } from './index'
|
|
4
|
+
|
|
5
|
+
describe('the /participant entry re-exports the transcription type next to ParticipantAttachment', () => {
|
|
6
|
+
it('lets a host type the field without importing the contracts package (checked by tsc)', () => {
|
|
7
|
+
const transcription: ParticipantTranscription = { status: 'pending' }
|
|
8
|
+
const attachment: Pick<ParticipantAttachment, 'transcription'> = { transcription }
|
|
9
|
+
expect(attachment.transcription?.status).toBe('pending')
|
|
10
|
+
})
|
|
11
|
+
})
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { act } from 'react'
|
|
2
|
+
|
|
3
|
+
import { afterAll, afterEach, beforeAll, beforeEach, jest } from 'bun:test'
|
|
4
|
+
|
|
5
|
+
import { setupDom, type DomHarness } from '../domHarness.test-helper'
|
|
6
|
+
import type { ParticipantAudioRecordingOptions } from './participantAudio.types'
|
|
7
|
+
import { useParticipantAudioRecorder } from './useParticipantAudioRecorder'
|
|
8
|
+
|
|
9
|
+
export type RecorderApi = ReturnType<typeof useParticipantAudioRecorder>
|
|
10
|
+
|
|
11
|
+
export class FakeTrack {
|
|
12
|
+
stopCount = 0
|
|
13
|
+
onended: (() => void) | null = null
|
|
14
|
+
onmute: (() => void) | null = null
|
|
15
|
+
onunmute: (() => void) | null = null
|
|
16
|
+
stop(): void {
|
|
17
|
+
this.stopCount += 1
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export class FakeStream {
|
|
22
|
+
readonly track = new FakeTrack()
|
|
23
|
+
getTracks(): FakeTrack[] {
|
|
24
|
+
return [this.track]
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Mutable state shared by the recorder DOM suites; each suite file builds its own through `setupRecorderDom`. */
|
|
29
|
+
export type RecorderDomKit = {
|
|
30
|
+
harness: DomHarness
|
|
31
|
+
api: RecorderApi
|
|
32
|
+
stream: FakeStream
|
|
33
|
+
supportedTypes: readonly string[]
|
|
34
|
+
chunkBytes: number
|
|
35
|
+
revoked: string[]
|
|
36
|
+
captureEvents: boolean[]
|
|
37
|
+
resolveStream: ((value: FakeStream) => void) | undefined
|
|
38
|
+
getUserMediaImpl: () => Promise<FakeStream>
|
|
39
|
+
mountProbe: (options?: ParticipantAudioRecordingOptions) => ReturnType<DomHarness['mount']>
|
|
40
|
+
start: () => Promise<void>
|
|
41
|
+
advance: (milliseconds: number) => Promise<void>
|
|
42
|
+
recordFor: (milliseconds: number) => Promise<void>
|
|
43
|
+
deferStream: () => void
|
|
44
|
+
hidePage: () => void
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function setupRecorderDom(): RecorderDomKit {
|
|
48
|
+
class FakeMediaRecorder {
|
|
49
|
+
static isTypeSupported(mimeType: string): boolean {
|
|
50
|
+
return kit.supportedTypes.includes(mimeType)
|
|
51
|
+
}
|
|
52
|
+
state: 'inactive' | 'recording' = 'inactive'
|
|
53
|
+
ondataavailable: ((event: { data: Blob }) => void) | null = null
|
|
54
|
+
onstop: (() => void) | null = null
|
|
55
|
+
onerror: (() => void) | null = null
|
|
56
|
+
constructor(readonly stream: FakeStream, readonly options: { mimeType: string }) {}
|
|
57
|
+
start(): void {
|
|
58
|
+
this.state = 'recording'
|
|
59
|
+
}
|
|
60
|
+
stop(): void {
|
|
61
|
+
this.state = 'inactive'
|
|
62
|
+
this.ondataavailable?.({ data: new Blob([new Uint8Array(kit.chunkBytes)]) })
|
|
63
|
+
this.onstop?.()
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function Probe({ options, channelMaxBytes }: { options?: ParticipantAudioRecordingOptions; channelMaxBytes?: number }) {
|
|
68
|
+
kit.api = useParticipantAudioRecorder({ options: { ...options, onCaptureChange: (isCapturing) => kit.captureEvents.push(isCapturing) }, channelMaxBytes })
|
|
69
|
+
return null
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const kit = {
|
|
73
|
+
mountProbe: (options?: ParticipantAudioRecordingOptions) => kit.harness.mount(<Probe options={options} />),
|
|
74
|
+
start: async () => {
|
|
75
|
+
await act(async () => {
|
|
76
|
+
void kit.api.start()
|
|
77
|
+
})
|
|
78
|
+
},
|
|
79
|
+
advance: async (milliseconds: number) => {
|
|
80
|
+
await act(async () => {
|
|
81
|
+
jest.advanceTimersByTime(milliseconds)
|
|
82
|
+
})
|
|
83
|
+
},
|
|
84
|
+
recordFor: async (milliseconds: number) => {
|
|
85
|
+
await kit.start()
|
|
86
|
+
await kit.advance(milliseconds)
|
|
87
|
+
},
|
|
88
|
+
deferStream: () => {
|
|
89
|
+
kit.getUserMediaImpl = () => new Promise<FakeStream>((resolve) => (kit.resolveStream = resolve))
|
|
90
|
+
},
|
|
91
|
+
hidePage: () => {
|
|
92
|
+
Object.defineProperty(document, 'visibilityState', { configurable: true, get: () => 'hidden' })
|
|
93
|
+
Object.defineProperty(document, 'hidden', { configurable: true, get: () => true })
|
|
94
|
+
act(() => {
|
|
95
|
+
document.dispatchEvent(new Event('visibilitychange'))
|
|
96
|
+
})
|
|
97
|
+
Object.defineProperty(document, 'visibilityState', { configurable: true, get: () => 'visible' })
|
|
98
|
+
Object.defineProperty(document, 'hidden', { configurable: true, get: () => false })
|
|
99
|
+
},
|
|
100
|
+
} as unknown as RecorderDomKit
|
|
101
|
+
|
|
102
|
+
beforeAll(async () => {
|
|
103
|
+
kit.harness = await setupDom()
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
afterAll(() => kit.harness.teardown())
|
|
107
|
+
|
|
108
|
+
beforeEach(() => {
|
|
109
|
+
jest.useFakeTimers()
|
|
110
|
+
kit.supportedTypes = ['audio/ogg;codecs=opus', 'audio/mp4', 'audio/webm']
|
|
111
|
+
kit.chunkBytes = 4096
|
|
112
|
+
kit.revoked = []
|
|
113
|
+
kit.captureEvents = []
|
|
114
|
+
kit.stream = new FakeStream()
|
|
115
|
+
kit.resolveStream = undefined
|
|
116
|
+
kit.getUserMediaImpl = async () => kit.stream
|
|
117
|
+
;(globalThis as Record<string, unknown>).MediaRecorder = FakeMediaRecorder
|
|
118
|
+
;(navigator as unknown as Record<string, unknown>).mediaDevices = { getUserMedia: () => kit.getUserMediaImpl() }
|
|
119
|
+
URL.createObjectURL = () => 'blob:fake-recording'
|
|
120
|
+
URL.revokeObjectURL = (url: string) => {
|
|
121
|
+
kit.revoked.push(url)
|
|
122
|
+
}
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
afterEach(() => {
|
|
126
|
+
jest.useRealTimers()
|
|
127
|
+
delete (globalThis as Record<string, unknown>).MediaRecorder
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
return kit
|
|
131
|
+
}
|