@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,90 @@
|
|
|
1
|
+
import { useState } from 'react'
|
|
2
|
+
|
|
3
|
+
import type { ParticipantTranscription } from '@adatechnology/conversation-contracts'
|
|
4
|
+
|
|
5
|
+
import type { ParticipantAudioLabels } from './participantAudioLabels'
|
|
6
|
+
import { useTranscriptionOverflow } from './useTranscriptionOverflow'
|
|
7
|
+
import { useTranscriptionReadyAnnouncement } from './useTranscriptionReadyAnnouncement'
|
|
8
|
+
|
|
9
|
+
const MILLISECONDS_PER_MINUTE = 60_000
|
|
10
|
+
|
|
11
|
+
export type ParticipantAudioTranscriptionProps = {
|
|
12
|
+
readonly attachmentId: string
|
|
13
|
+
readonly transcription: ParticipantTranscription | undefined
|
|
14
|
+
readonly labels: ParticipantAudioLabels
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
type ReadyTranscriptionBlockProps = {
|
|
18
|
+
readonly transcription: Extract<ParticipantTranscription, { status: 'ready' }>
|
|
19
|
+
readonly labels: ParticipantAudioLabels
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function resolveTooLongMinutes(transcription: ParticipantTranscription | undefined): number | undefined {
|
|
23
|
+
if (transcription?.status !== 'failed' || transcription.reason !== 'too_long') return undefined
|
|
24
|
+
if (typeof transcription.maxDurationMs !== 'number') return undefined
|
|
25
|
+
const minutes = Math.floor(transcription.maxDurationMs / MILLISECONDS_PER_MINUTE)
|
|
26
|
+
return minutes >= 1 ? minutes : undefined
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// A host that skipped validation can hand us a ready block without a string text: treat it as absent.
|
|
30
|
+
function dropUnusableTranscription(transcription: ParticipantTranscription | undefined): ParticipantTranscription | undefined {
|
|
31
|
+
if (transcription?.status === 'ready' && typeof transcription.text !== 'string') return undefined
|
|
32
|
+
return transcription
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function ParticipantAudioTranscription({ attachmentId, transcription: received, labels }: ParticipantAudioTranscriptionProps) {
|
|
36
|
+
const transcription = dropUnusableTranscription(received)
|
|
37
|
+
useTranscriptionReadyAnnouncement({
|
|
38
|
+
attachmentId,
|
|
39
|
+
status: transcription?.status,
|
|
40
|
+
text: labels.transcriptionReady,
|
|
41
|
+
})
|
|
42
|
+
if (transcription?.status === 'pending') {
|
|
43
|
+
return <p className="cv-p-transcription cv-p-transcription--pending">{labels.transcribing}</p>
|
|
44
|
+
}
|
|
45
|
+
if (transcription?.status === 'ready') return <ReadyTranscriptionBlock transcription={transcription} labels={labels} />
|
|
46
|
+
const minutes = resolveTooLongMinutes(transcription)
|
|
47
|
+
if (minutes === undefined) return null
|
|
48
|
+
return (
|
|
49
|
+
<p className="cv-p-transcription cv-p-transcription--too-long">
|
|
50
|
+
{labels.transcriptionTooLong.replace('{minutes}', String(minutes))}
|
|
51
|
+
</p>
|
|
52
|
+
)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function ReadyTranscriptionBlock({ transcription, labels }: ReadyTranscriptionBlockProps) {
|
|
56
|
+
const [isExpanded, setIsExpanded] = useState(false)
|
|
57
|
+
const { textRef, isOverflowing } = useTranscriptionOverflow(transcription.text, isExpanded)
|
|
58
|
+
const hasText = transcription.text !== ''
|
|
59
|
+
const showsToggle = hasText && (isOverflowing || isExpanded)
|
|
60
|
+
|
|
61
|
+
return (
|
|
62
|
+
<div className="cv-p-transcription">
|
|
63
|
+
<p className="cv-p-transcription__notice">
|
|
64
|
+
{labels.machineTranscription}
|
|
65
|
+
</p>
|
|
66
|
+
{hasText ? (
|
|
67
|
+
<p
|
|
68
|
+
ref={textRef}
|
|
69
|
+
className="cv-p-transcription__text"
|
|
70
|
+
lang={transcription.language}
|
|
71
|
+
data-expanded={isExpanded ? 'true' : undefined}
|
|
72
|
+
>
|
|
73
|
+
{transcription.text}
|
|
74
|
+
</p>
|
|
75
|
+
) : (
|
|
76
|
+
<p className="cv-p-transcription__empty">{labels.emptyTranscription}</p>
|
|
77
|
+
)}
|
|
78
|
+
{showsToggle ? (
|
|
79
|
+
<button
|
|
80
|
+
type="button"
|
|
81
|
+
className="cv-p-transcription__toggle"
|
|
82
|
+
aria-expanded={isExpanded}
|
|
83
|
+
onClick={() => setIsExpanded((current) => !current)}
|
|
84
|
+
>
|
|
85
|
+
{isExpanded ? labels.collapseTranscription : labels.showFullTranscription}
|
|
86
|
+
</button>
|
|
87
|
+
) : null}
|
|
88
|
+
</div>
|
|
89
|
+
)
|
|
90
|
+
}
|
|
@@ -4,7 +4,10 @@ import { channelCapabilityFor } from '../channelCapability'
|
|
|
4
4
|
import type { ConversationChannel } from '../conversationChannel'
|
|
5
5
|
import { formatFileSize } from '../lib/format'
|
|
6
6
|
import type { QuickReply } from '../quickReplies/quickReply.types'
|
|
7
|
+
import { ComposerAudioRow } from './ParticipantComposerAudio'
|
|
7
8
|
import { AttachControl, MessageField, SendButton } from './ParticipantComposerControls'
|
|
9
|
+
import type { ParticipantAudioRecordingOptions } from './participantAudio.types'
|
|
10
|
+
import { resolveComposerAudio } from './participantAudioSupport'
|
|
8
11
|
import { maxBytesForChannel, reduceFilesSelection, type ParticipantFileRejection } from './participantFileValidation'
|
|
9
12
|
import type { ParticipantConversationsLabels } from './participantLabels'
|
|
10
13
|
import { applyQuickReply } from './participantQuickReply'
|
|
@@ -26,6 +29,10 @@ export type ParticipantComposerProps = {
|
|
|
26
29
|
readonly acceptedTypes?: readonly string[]
|
|
27
30
|
/** Tapping a chip fills the text; it never sends. Absent or empty means no strip. */
|
|
28
31
|
readonly quickReplies?: readonly QuickReply[]
|
|
32
|
+
/** Present turns the microphone on (an empty field, a browser and a channel that record); `{}` takes the defaults. */
|
|
33
|
+
readonly audioRecording?: ParticipantAudioRecordingOptions
|
|
34
|
+
/** Receives the recorded audio, already checked; sent as a message of its own, never through the draft. */
|
|
35
|
+
readonly onSendAudio?: (file: File) => void
|
|
29
36
|
}
|
|
30
37
|
|
|
31
38
|
type QuickReplyStripProps = {
|
|
@@ -95,8 +102,49 @@ function useFileSelection(props: ParticipantComposerProps) {
|
|
|
95
102
|
return { rejection, handleFilesChosen, handleRemoveFile }
|
|
96
103
|
}
|
|
97
104
|
|
|
105
|
+
type ComposerRowProps = {
|
|
106
|
+
readonly composer: ParticipantComposerProps
|
|
107
|
+
readonly canSend: boolean
|
|
108
|
+
readonly onFilesChosen: (event: ChangeEvent<HTMLInputElement>) => void
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function ComposerRow({ composer, canSend, onFilesChosen }: ComposerRowProps) {
|
|
112
|
+
const { value, onChange, files, labels, channel = 'app', disabled = false } = composer
|
|
113
|
+
const attach = channelCapabilityFor(channel).attachments.accepted ? (
|
|
114
|
+
<AttachControl labels={labels} disabled={disabled} acceptedTypes={composer.acceptedTypes} onChosen={onFilesChosen} />
|
|
115
|
+
) : null
|
|
116
|
+
const field = (
|
|
117
|
+
<MessageField
|
|
118
|
+
value={value}
|
|
119
|
+
label={labels.messageInputLabel}
|
|
120
|
+
placeholder={labels.messageInputPlaceholder}
|
|
121
|
+
disabled={disabled}
|
|
122
|
+
maxLength={composer.maxLength}
|
|
123
|
+
onChange={onChange}
|
|
124
|
+
/>
|
|
125
|
+
)
|
|
126
|
+
const sendButton = <SendButton label={labels.send} disabled={!canSend} />
|
|
127
|
+
const audio = resolveComposerAudio(composer)
|
|
128
|
+
if (!audio) {
|
|
129
|
+
return (
|
|
130
|
+
<div className="cv-p-composer__row">
|
|
131
|
+
{attach}
|
|
132
|
+
{field}
|
|
133
|
+
{sendButton}
|
|
134
|
+
</div>
|
|
135
|
+
)
|
|
136
|
+
}
|
|
137
|
+
const isDraftEmpty = value.trim().length === 0 && files.length === 0
|
|
138
|
+
return (
|
|
139
|
+
<ComposerAudioRow settings={audio} labels={labels} disabled={disabled} isDraftEmpty={isDraftEmpty} sendButton={sendButton}>
|
|
140
|
+
{attach}
|
|
141
|
+
{field}
|
|
142
|
+
</ComposerAudioRow>
|
|
143
|
+
)
|
|
144
|
+
}
|
|
145
|
+
|
|
98
146
|
export function ParticipantComposer(props: ParticipantComposerProps) {
|
|
99
|
-
const { value, onChange, files, onSend, labels,
|
|
147
|
+
const { value, onChange, files, onSend, labels, disabled = false, isSending = false } = props
|
|
100
148
|
const { rejection, handleFilesChosen, handleRemoveFile } = useFileSelection(props)
|
|
101
149
|
const canSend = !disabled && !isSending && (value.trim().length > 0 || files.length > 0)
|
|
102
150
|
|
|
@@ -116,20 +164,7 @@ export function ParticipantComposer(props: ParticipantComposerProps) {
|
|
|
116
164
|
{rejectionText(rejection, labels)}
|
|
117
165
|
</p>
|
|
118
166
|
) : null}
|
|
119
|
-
<
|
|
120
|
-
{channelCapabilityFor(channel).attachments.accepted ? (
|
|
121
|
-
<AttachControl labels={labels} disabled={disabled} acceptedTypes={props.acceptedTypes} onChosen={handleFilesChosen} />
|
|
122
|
-
) : null}
|
|
123
|
-
<MessageField
|
|
124
|
-
value={value}
|
|
125
|
-
label={labels.messageInputLabel}
|
|
126
|
-
placeholder={labels.messageInputPlaceholder}
|
|
127
|
-
disabled={disabled}
|
|
128
|
-
maxLength={props.maxLength}
|
|
129
|
-
onChange={onChange}
|
|
130
|
-
/>
|
|
131
|
-
<SendButton label={labels.send} disabled={!canSend} />
|
|
132
|
-
</div>
|
|
167
|
+
<ComposerRow composer={props} canSend={canSend} onFilesChosen={handleFilesChosen} />
|
|
133
168
|
</form>
|
|
134
169
|
)
|
|
135
170
|
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { useEffect, useRef, type ReactNode, type RefObject } from 'react'
|
|
2
|
+
|
|
3
|
+
import { maxBytesForChannel } from './participantFileValidation'
|
|
4
|
+
import { resolveParticipantAudioLabels } from './participantAudioLabels'
|
|
5
|
+
import type { ComposerAudioSettings } from './participantAudioSupport'
|
|
6
|
+
import type { RecorderStatus } from './participantAudioRecorderState'
|
|
7
|
+
import type { ParticipantConversationsLabels } from './participantLabels'
|
|
8
|
+
import { ParticipantAudioRecorderView, resolveRecorderErrorMessage } from './ParticipantAudioRecorderView'
|
|
9
|
+
import { RecordAudioButton, RecorderError } from './ParticipantRecorderParts'
|
|
10
|
+
import { useParticipantAudioRecorder, type ParticipantAudioRecorder } from './useParticipantAudioRecorder'
|
|
11
|
+
|
|
12
|
+
export type ComposerAudioRowProps = {
|
|
13
|
+
readonly settings: ComposerAudioSettings
|
|
14
|
+
readonly labels: ParticipantConversationsLabels
|
|
15
|
+
readonly disabled: boolean
|
|
16
|
+
readonly isDraftEmpty: boolean
|
|
17
|
+
/** The send button of the text path; the microphone only takes its place while the draft is empty. */
|
|
18
|
+
readonly sendButton: ReactNode
|
|
19
|
+
/** Attach control and message field, drawn only while no recording is in progress. */
|
|
20
|
+
readonly children: ReactNode
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function isCapturingStatus(status: RecorderStatus): boolean {
|
|
24
|
+
return status === 'recording' || status === 'review'
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function useRestoreMicrophoneFocus(status: RecorderStatus): RefObject<HTMLButtonElement | null> {
|
|
28
|
+
const microphoneRef = useRef<HTMLButtonElement>(null)
|
|
29
|
+
const wasCapturingRef = useRef(false)
|
|
30
|
+
useEffect(() => {
|
|
31
|
+
const isCapturing = isCapturingStatus(status)
|
|
32
|
+
if (wasCapturingRef.current && !isCapturing) microphoneRef.current?.focus()
|
|
33
|
+
wasCapturingRef.current = isCapturing
|
|
34
|
+
}, [status])
|
|
35
|
+
return microphoneRef
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
type RecorderLineProps = {
|
|
39
|
+
readonly recorder: ParticipantAudioRecorder
|
|
40
|
+
readonly labels: ParticipantConversationsLabels
|
|
41
|
+
readonly onSendAudio: (file: File) => void
|
|
42
|
+
readonly disabled: boolean
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function RecorderLine({ recorder, labels, onSendAudio, disabled }: RecorderLineProps) {
|
|
46
|
+
function handleSendAudio(): void {
|
|
47
|
+
const file = recorder.confirm()
|
|
48
|
+
if (file) onSendAudio(file)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return (
|
|
52
|
+
<ParticipantAudioRecorderView
|
|
53
|
+
state={recorder.status}
|
|
54
|
+
elapsedMs={recorder.elapsedMs}
|
|
55
|
+
labels={labels}
|
|
56
|
+
disabled={disabled}
|
|
57
|
+
maxDurationMs={recorder.limits.maxDurationMs}
|
|
58
|
+
review={{ url: recorder.recorded?.objectUrl, audioRef: recorder.audioRef, durationMs: recorder.recorded?.durationMs }}
|
|
59
|
+
actions={{ onStop: recorder.stop, onSend: handleSendAudio, onDiscard: recorder.discard }}
|
|
60
|
+
/>
|
|
61
|
+
)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function ComposerAudioRow({ settings, labels, disabled, isDraftEmpty, sendButton, children }: ComposerAudioRowProps) {
|
|
65
|
+
const recorder = useParticipantAudioRecorder({
|
|
66
|
+
options: settings.audioRecording,
|
|
67
|
+
channelMaxBytes: maxBytesForChannel(settings.channel),
|
|
68
|
+
})
|
|
69
|
+
const microphoneRef = useRestoreMicrophoneFocus(recorder.status)
|
|
70
|
+
const audioLabels = resolveParticipantAudioLabels(labels)
|
|
71
|
+
const errorMessage = resolveRecorderErrorMessage(recorder.status, audioLabels)
|
|
72
|
+
|
|
73
|
+
if (isCapturingStatus(recorder.status)) {
|
|
74
|
+
return <RecorderLine recorder={recorder} labels={labels} onSendAudio={settings.onSendAudio} disabled={disabled} />
|
|
75
|
+
}
|
|
76
|
+
return (
|
|
77
|
+
<>
|
|
78
|
+
{errorMessage ? <RecorderError message={errorMessage} /> : null}
|
|
79
|
+
<div className="cv-p-composer__row">
|
|
80
|
+
{children}
|
|
81
|
+
{isDraftEmpty ? (
|
|
82
|
+
<RecordAudioButton
|
|
83
|
+
label={audioLabels.recordAudio}
|
|
84
|
+
isBusy={recorder.status === 'requesting'}
|
|
85
|
+
disabled={disabled}
|
|
86
|
+
onClick={() => void recorder.start()}
|
|
87
|
+
buttonRef={microphoneRef}
|
|
88
|
+
/>
|
|
89
|
+
) : (
|
|
90
|
+
sendButton
|
|
91
|
+
)}
|
|
92
|
+
</div>
|
|
93
|
+
</>
|
|
94
|
+
)
|
|
95
|
+
}
|
|
@@ -13,6 +13,7 @@ import type {
|
|
|
13
13
|
ParticipantSubjectIconRenderer,
|
|
14
14
|
} from './participant.types'
|
|
15
15
|
import type { ParticipantConversationsApi, ParticipantPendingMessage } from './participantApi.types'
|
|
16
|
+
import type { ParticipantAudioRecordingOptions } from './participantAudio.types'
|
|
16
17
|
import type { ParticipantConversationsLabels } from './participantLabels'
|
|
17
18
|
import { useParticipantInbox } from './useParticipantInbox'
|
|
18
19
|
|
|
@@ -41,6 +42,10 @@ export type ParticipantConversationsProps = {
|
|
|
41
42
|
renderAuthorAvatar?: ParticipantAuthorAvatarRenderer
|
|
42
43
|
/** Speech-bubble tail on the bottom corner; false removes it. Default true. */
|
|
43
44
|
tail?: boolean
|
|
45
|
+
/** Absent draws no microphone. Present lets the user record and send an audio as a message of its own. */
|
|
46
|
+
audioRecording?: ParticipantAudioRecordingOptions
|
|
47
|
+
/** Return a node to replace the composer for this conversation (notice + host actions); undefined keeps the composer. `closed` still wins. Evaluated every render. */
|
|
48
|
+
renderComposerLock?: (conversation: ParticipantConversationSummary) => ReactNode | undefined
|
|
44
49
|
}
|
|
45
50
|
|
|
46
51
|
export function ParticipantConversations(props: ParticipantConversationsProps) {
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import type { Ref } from 'react'
|
|
2
|
+
|
|
3
|
+
import { Mic, Send, Square, Trash2 } from 'lucide-react'
|
|
4
|
+
|
|
5
|
+
import { ParticipantAudioPlayer } from './ParticipantAudioPlayer'
|
|
6
|
+
import type { ParticipantAudioLabels } from './participantAudioLabels'
|
|
7
|
+
|
|
8
|
+
export type RecordAudioButtonProps = {
|
|
9
|
+
readonly label: string
|
|
10
|
+
readonly isBusy: boolean
|
|
11
|
+
readonly disabled: boolean
|
|
12
|
+
readonly onClick?: () => void
|
|
13
|
+
readonly buttonRef?: Ref<HTMLButtonElement>
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function RecordAudioButton({ label, isBusy, disabled, onClick, buttonRef }: RecordAudioButtonProps) {
|
|
17
|
+
return (
|
|
18
|
+
<button
|
|
19
|
+
ref={buttonRef}
|
|
20
|
+
type="button"
|
|
21
|
+
className="cv-p-button cv-p-button--primary cv-p-composer__record"
|
|
22
|
+
aria-label={label}
|
|
23
|
+
data-cv-tooltip={label}
|
|
24
|
+
aria-busy={isBusy ? true : undefined}
|
|
25
|
+
disabled={disabled || isBusy}
|
|
26
|
+
onClick={onClick}
|
|
27
|
+
>
|
|
28
|
+
<Mic size={20} aria-hidden="true" focusable="false" />
|
|
29
|
+
</button>
|
|
30
|
+
)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function RecorderError({ message }: { readonly message: string }) {
|
|
34
|
+
return (
|
|
35
|
+
<p className="cv-p-composer__error" role="alert">
|
|
36
|
+
{message}
|
|
37
|
+
</p>
|
|
38
|
+
)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function focusOnMount(element: HTMLElement | null): void {
|
|
42
|
+
element?.focus()
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
type RecorderGroupProps = {
|
|
46
|
+
readonly labels: ParticipantAudioLabels
|
|
47
|
+
readonly onDiscard?: () => void
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function DiscardButton({ labels, onDiscard }: RecorderGroupProps) {
|
|
51
|
+
return (
|
|
52
|
+
<button
|
|
53
|
+
type="button"
|
|
54
|
+
className="cv-p-button cv-p-recorder__discard"
|
|
55
|
+
aria-label={labels.discardAudio}
|
|
56
|
+
data-cv-tooltip={labels.discardAudio}
|
|
57
|
+
onClick={onDiscard}
|
|
58
|
+
>
|
|
59
|
+
<Trash2 size={20} aria-hidden="true" focusable="false" />
|
|
60
|
+
</button>
|
|
61
|
+
)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export type RecordingGroupProps = RecorderGroupProps & {
|
|
65
|
+
readonly clockText: string
|
|
66
|
+
readonly onStop?: () => void
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function RecordingGroup({ labels, clockText, onStop, onDiscard }: RecordingGroupProps) {
|
|
70
|
+
return (
|
|
71
|
+
<div className="cv-p-recorder" role="group" aria-label={labels.recordingInProgress}>
|
|
72
|
+
<DiscardButton labels={labels} onDiscard={onDiscard} />
|
|
73
|
+
<span className="cv-p-recorder__dot" aria-hidden="true" />
|
|
74
|
+
<span className="cv-p-recorder__elapsed" role="timer">
|
|
75
|
+
{clockText}
|
|
76
|
+
</span>
|
|
77
|
+
<button
|
|
78
|
+
type="button"
|
|
79
|
+
ref={focusOnMount}
|
|
80
|
+
className="cv-p-button cv-p-button--primary cv-p-recorder__stop"
|
|
81
|
+
aria-label={labels.stopRecording}
|
|
82
|
+
data-cv-tooltip={labels.stopRecording}
|
|
83
|
+
onClick={onStop}
|
|
84
|
+
>
|
|
85
|
+
<Square size={20} aria-hidden="true" focusable="false" />
|
|
86
|
+
</button>
|
|
87
|
+
</div>
|
|
88
|
+
)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export type ReviewGroupProps = RecorderGroupProps & {
|
|
92
|
+
readonly url?: string
|
|
93
|
+
readonly audioRef?: Ref<HTMLAudioElement>
|
|
94
|
+
readonly durationMs?: number | undefined
|
|
95
|
+
readonly onSend?: () => void
|
|
96
|
+
readonly disabled?: boolean
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function ReviewGroup({ labels, url, audioRef, durationMs, onSend, onDiscard, disabled = false }: ReviewGroupProps) {
|
|
100
|
+
return (
|
|
101
|
+
<div className="cv-p-recorder cv-p-recorder--review" role="group" aria-label={labels.reviewAudio}>
|
|
102
|
+
<p className="cv-p-recorder__hint" aria-hidden="true">{labels.reviewAudio}</p>
|
|
103
|
+
<DiscardButton labels={labels} onDiscard={onDiscard} />
|
|
104
|
+
<ParticipantAudioPlayer labels={labels} url={url} durationMs={durationMs} mediaRef={audioRef} />
|
|
105
|
+
<button
|
|
106
|
+
type="button"
|
|
107
|
+
ref={focusOnMount}
|
|
108
|
+
className="cv-p-button cv-p-button--primary cv-p-recorder__send"
|
|
109
|
+
aria-label={labels.sendAudio}
|
|
110
|
+
data-cv-tooltip={labels.sendAudio}
|
|
111
|
+
disabled={disabled}
|
|
112
|
+
onClick={onSend}
|
|
113
|
+
>
|
|
114
|
+
<Send size={20} aria-hidden="true" focusable="false" />
|
|
115
|
+
</button>
|
|
116
|
+
</div>
|
|
117
|
+
)
|
|
118
|
+
}
|
|
@@ -128,6 +128,52 @@ describe('ParticipantThread', () => {
|
|
|
128
128
|
expect(markup).not.toContain('<textarea')
|
|
129
129
|
})
|
|
130
130
|
|
|
131
|
+
it('replaces the composer with the lock node inside the lock region', () => {
|
|
132
|
+
const markup = render({ renderComposerLock: () => <p>Assumed by Ana</p> })
|
|
133
|
+
|
|
134
|
+
expect(markup).toContain(
|
|
135
|
+
'<div class="cv-p-thread__lock" role="region" aria-live="polite"><p>Assumed by Ana</p></div>',
|
|
136
|
+
)
|
|
137
|
+
expect(markup).not.toContain('<form')
|
|
138
|
+
expect(markup).not.toContain('<textarea')
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
it('keeps the composer when renderComposerLock returns undefined or null', () => {
|
|
142
|
+
expect(render({ renderComposerLock: () => undefined })).toContain('<form')
|
|
143
|
+
expect(render({ renderComposerLock: () => null })).toContain('<form')
|
|
144
|
+
expect(render({ renderComposerLock: () => undefined })).not.toContain('cv-p-thread__lock')
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
it('lets the closed notice win over the lock', () => {
|
|
148
|
+
const markup = render({
|
|
149
|
+
conversation: buildConversation({ subjectId: '1', status: 'closed' }),
|
|
150
|
+
renderComposerLock: () => <p>Assumed by Ana</p>,
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
expect(markup).toContain('This conversation is closed')
|
|
154
|
+
expect(markup).not.toContain('Assumed by Ana')
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
it('passes the conversation to renderComposerLock and brings the draft back when it unlocks', () => {
|
|
158
|
+
const draft = { value: 'half typed', onChange: () => undefined, files: [], onFilesChange: () => undefined }
|
|
159
|
+
const conversation = buildConversation({ subjectId: '7' })
|
|
160
|
+
const seen: string[] = []
|
|
161
|
+
|
|
162
|
+
const locked = render({
|
|
163
|
+
draft,
|
|
164
|
+
conversation,
|
|
165
|
+
renderComposerLock: (received) => {
|
|
166
|
+
seen.push(received.subjectId)
|
|
167
|
+
return <p>Locked</p>
|
|
168
|
+
},
|
|
169
|
+
})
|
|
170
|
+
const unlocked = render({ draft, conversation, renderComposerLock: () => undefined })
|
|
171
|
+
|
|
172
|
+
expect(seen).toEqual(['7'])
|
|
173
|
+
expect(locked).not.toContain('half typed')
|
|
174
|
+
expect(unlocked).toContain('half typed')
|
|
175
|
+
})
|
|
176
|
+
|
|
131
177
|
it('shows the composer when the conversation is open', () => {
|
|
132
178
|
const markup = render()
|
|
133
179
|
|
|
@@ -21,6 +21,7 @@ import { ParticipantScrollToLatest } from './ParticipantScrollToLatest'
|
|
|
21
21
|
import { ParticipantTimeline } from './ParticipantTimeline'
|
|
22
22
|
import { ParticipantThreadHeader } from './ParticipantThreadHeader'
|
|
23
23
|
import type { ParticipantSubjectGroup, ParticipantSubjectIconRenderer } from './participant.types'
|
|
24
|
+
import type { ParticipantAudioRecordingOptions } from './participantAudio.types'
|
|
24
25
|
|
|
25
26
|
export type ParticipantThreadDraft = {
|
|
26
27
|
readonly value: string
|
|
@@ -71,6 +72,12 @@ export type ParticipantThreadProps = {
|
|
|
71
72
|
readonly counterpartLabel?: string
|
|
72
73
|
/** Host slot at the end of the header bar (close, reopen...). */
|
|
73
74
|
readonly headerActions?: ReactNode
|
|
75
|
+
/** Absent draws no microphone. Present turns it on when the field is empty and the browser and channel record. */
|
|
76
|
+
readonly audioRecording?: ParticipantAudioRecordingOptions
|
|
77
|
+
/** Receives the recorded audio; the controller sends it as a message of its own. */
|
|
78
|
+
readonly onSendAudio?: (file: File) => void
|
|
79
|
+
/** Return a node to replace the composer for this conversation (notice + host actions); undefined keeps the composer. Evaluated every render. */
|
|
80
|
+
readonly renderComposerLock?: (conversation: ParticipantConversationSummary) => ReactNode | undefined
|
|
74
81
|
}
|
|
75
82
|
|
|
76
83
|
function Footer({ props }: { readonly props: ParticipantThreadProps }) {
|
|
@@ -78,6 +85,14 @@ function Footer({ props }: { readonly props: ParticipantThreadProps }) {
|
|
|
78
85
|
if (conversation.status === 'closed') {
|
|
79
86
|
return <p className="cv-p-thread__closed">{labels.closedNotice}</p>
|
|
80
87
|
}
|
|
88
|
+
const lock = props.renderComposerLock?.(conversation)
|
|
89
|
+
if (lock !== undefined && lock !== null) {
|
|
90
|
+
return (
|
|
91
|
+
<div className="cv-p-thread__lock" role="region" aria-live="polite">
|
|
92
|
+
{lock}
|
|
93
|
+
</div>
|
|
94
|
+
)
|
|
95
|
+
}
|
|
81
96
|
return (
|
|
82
97
|
<ParticipantComposer
|
|
83
98
|
value={draft.value}
|
|
@@ -91,6 +106,8 @@ function Footer({ props }: { readonly props: ParticipantThreadProps }) {
|
|
|
91
106
|
maxLength={props.maxLength}
|
|
92
107
|
acceptedTypes={props.acceptedTypes}
|
|
93
108
|
quickReplies={props.quickReplies}
|
|
109
|
+
audioRecording={props.audioRecording}
|
|
110
|
+
onSendAudio={props.onSendAudio}
|
|
94
111
|
/>
|
|
95
112
|
)
|
|
96
113
|
}
|
|
@@ -104,6 +104,8 @@ function LoadedThread(props: LoadedThreadProps) {
|
|
|
104
104
|
avatars={props.avatars}
|
|
105
105
|
renderAuthorAvatar={props.renderAuthorAvatar}
|
|
106
106
|
tail={props.tail}
|
|
107
|
+
audioRecording={props.audioRecording}
|
|
108
|
+
renderComposerLock={props.renderComposerLock}
|
|
107
109
|
/>
|
|
108
110
|
)
|
|
109
111
|
}
|
package/src/participant/index.ts
CHANGED
|
@@ -4,6 +4,8 @@ export type { ConversationThreadApi } from './conversationThreadApi'
|
|
|
4
4
|
export type { ParticipantPerspective } from './participantPerspective'
|
|
5
5
|
export type { ParticipantAuthorAvatarRenderer } from './ParticipantAuthorAvatar'
|
|
6
6
|
export { groupParticipantConversations } from './participantGrouping'
|
|
7
|
+
export { DEFAULT_MAX_RECORDING_BYTES, type ParticipantAudioRecordingOptions } from './participantAudio.types'
|
|
8
|
+
export { DEFAULT_PARTICIPANT_AUDIO_LABELS, type ParticipantAudioLabels } from './participantAudioLabels'
|
|
7
9
|
export { DEFAULT_PARTICIPANT_CONVERSATIONS_LABELS, type ParticipantConversationsLabels } from './participantLabels'
|
|
8
10
|
export type {
|
|
9
11
|
ParticipantConversationsClassNames,
|
|
@@ -27,4 +29,5 @@ export type {
|
|
|
27
29
|
ParticipantConversationSummary,
|
|
28
30
|
ParticipantMessage,
|
|
29
31
|
ParticipantSubjectRef,
|
|
32
|
+
ParticipantTranscription,
|
|
30
33
|
} from '@adatechnology/conversation-contracts'
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export type ParticipantAudioRecordingOptions = {
|
|
2
|
+
/** Only lowers the default: a larger value is cut to DEFAULT_MAX_RECORDING_MILLISECONDS (5 min). */
|
|
3
|
+
readonly maxDurationMs?: number
|
|
4
|
+
/** Only lowers the default: a larger value is cut to DEFAULT_MAX_RECORDING_BYTES (16 MiB, the app audio policy). */
|
|
5
|
+
readonly maxSizeBytes?: number
|
|
6
|
+
/** True from `requesting` until it leaves `review`; the host feeds its capture registry. Never receives the audio. */
|
|
7
|
+
readonly onCaptureChange?: (isCapturing: boolean) => void
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export const DEFAULT_MAX_RECORDING_BYTES = 16 * 1024 * 1024
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test'
|
|
2
|
+
|
|
3
|
+
import { buildRecordedFile, formatRecordedFileName } from './participantAudioCapture'
|
|
4
|
+
|
|
5
|
+
describe('recorded file naming', () => {
|
|
6
|
+
it('names the file audio-YYYYMMDD-HHmmss.<ext> in UTC', () => {
|
|
7
|
+
expect(formatRecordedFileName(new Date('2026-10-11T09:05:03.999Z'), 'm4a')).toBe('audio-20261011-090503.m4a')
|
|
8
|
+
})
|
|
9
|
+
|
|
10
|
+
it('keeps the upload mime type of the chosen format', () => {
|
|
11
|
+
const file = buildRecordedFile(new Blob(['x']), { mimeType: 'audio/ogg;codecs=opus', uploadMimeType: 'audio/ogg', extension: 'ogg' }, new Date('2026-01-02T03:04:05Z'))
|
|
12
|
+
expect(file.name).toBe('audio-20260102-030405.ogg')
|
|
13
|
+
expect(file.type).toBe('audio/ogg')
|
|
14
|
+
})
|
|
15
|
+
})
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import type { RecordingFormat } from '../audioRecorderFormat'
|
|
2
|
+
import { MUTE_INTERRUPTION_DELAY_MS } from './participantAudioRecorderState'
|
|
3
|
+
|
|
4
|
+
export type CaptureSession = {
|
|
5
|
+
readonly format: RecordingFormat
|
|
6
|
+
readonly startedAt: number
|
|
7
|
+
readonly requestStop: () => void
|
|
8
|
+
readonly release: () => void
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export type OpenCaptureSessionParams = {
|
|
12
|
+
readonly stream: MediaStream
|
|
13
|
+
readonly format: RecordingFormat
|
|
14
|
+
readonly onInterrupt: () => void
|
|
15
|
+
readonly onStopped: (blob: Blob, durationMs: number) => void
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function stopStream(stream: MediaStream): void {
|
|
19
|
+
for (const track of stream.getTracks()) track.stop()
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function formatRecordedFileName(moment: Date, extension: string): string {
|
|
23
|
+
const iso = moment.toISOString()
|
|
24
|
+
const day = iso.slice(0, 10).replace(/-/g, '')
|
|
25
|
+
const time = iso.slice(11, 19).replace(/:/g, '')
|
|
26
|
+
return `audio-${day}-${time}.${extension}`
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function buildRecordedFile(blob: Blob, format: RecordingFormat, moment: Date = new Date()): File {
|
|
30
|
+
return new File([blob], formatRecordedFileName(moment, format.extension), { type: format.uploadMimeType })
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function createMuteWatch(onPersistentMute: () => void): { arm: () => void; clear: () => void } {
|
|
34
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
35
|
+
const clear = (): void => {
|
|
36
|
+
if (timer !== undefined) clearTimeout(timer)
|
|
37
|
+
timer = undefined
|
|
38
|
+
}
|
|
39
|
+
const arm = (): void => {
|
|
40
|
+
clear()
|
|
41
|
+
timer = setTimeout(onPersistentMute, MUTE_INTERRUPTION_DELAY_MS)
|
|
42
|
+
}
|
|
43
|
+
return { arm, clear }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function openCaptureSession({ stream, format, onInterrupt, onStopped }: OpenCaptureSessionParams): CaptureSession {
|
|
47
|
+
const recorder = new MediaRecorder(stream, { mimeType: format.mimeType })
|
|
48
|
+
const chunks: Blob[] = []
|
|
49
|
+
const startedAt = Date.now()
|
|
50
|
+
const muteWatch = createMuteWatch(onInterrupt)
|
|
51
|
+
for (const track of stream.getTracks()) {
|
|
52
|
+
track.onended = onInterrupt
|
|
53
|
+
track.onmute = muteWatch.arm
|
|
54
|
+
track.onunmute = muteWatch.clear
|
|
55
|
+
}
|
|
56
|
+
recorder.ondataavailable = (event) => {
|
|
57
|
+
if (event.data.size > 0) chunks.push(event.data)
|
|
58
|
+
}
|
|
59
|
+
recorder.onerror = onInterrupt
|
|
60
|
+
recorder.onstop = () => onStopped(new Blob(chunks, { type: format.uploadMimeType }), Date.now() - startedAt)
|
|
61
|
+
recorder.start()
|
|
62
|
+
|
|
63
|
+
let isStopRequested = false
|
|
64
|
+
// stop() makes the state inactive at once and the real `stop` event arrives later, so only that event may finish the capture
|
|
65
|
+
function requestStop(): void {
|
|
66
|
+
if (isStopRequested || recorder.state === 'inactive') return
|
|
67
|
+
isStopRequested = true
|
|
68
|
+
recorder.stop()
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function release(): void {
|
|
72
|
+
muteWatch.clear()
|
|
73
|
+
recorder.ondataavailable = null
|
|
74
|
+
recorder.onerror = null
|
|
75
|
+
recorder.onstop = null
|
|
76
|
+
for (const track of stream.getTracks()) {
|
|
77
|
+
track.onended = null
|
|
78
|
+
track.onmute = null
|
|
79
|
+
track.onunmute = null
|
|
80
|
+
}
|
|
81
|
+
stopStream(stream)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return { format, startedAt, requestStop, release }
|
|
85
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export type AudioPlaybackHandle = { readonly pause: () => void }
|
|
2
|
+
|
|
3
|
+
let activeHandle: AudioPlaybackHandle | undefined
|
|
4
|
+
|
|
5
|
+
/** One audio at a time on the page: whoever starts pauses the previous one. No provider, no context. */
|
|
6
|
+
export function claimAudioPlayback(handle: AudioPlaybackHandle): void {
|
|
7
|
+
if (activeHandle && activeHandle !== handle) activeHandle.pause()
|
|
8
|
+
activeHandle = handle
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function releaseAudioPlayback(handle: AudioPlaybackHandle): void {
|
|
12
|
+
if (activeHandle === handle) activeHandle = undefined
|
|
13
|
+
}
|