@adatechnology/conversations-ui 0.9.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 (90) 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-O6457XHK.js → chunk-WCZNIJ3M.js} +16 -1
  4. package/dist/{chunk-L3OFAQPS.js → chunk-Y326LLEP.js} +4 -14
  5. package/dist/index.d.ts +10 -3
  6. package/dist/index.js +3 -3
  7. package/dist/participant/index.d.ts +51 -3
  8. package/dist/participant/index.js +1363 -319
  9. package/dist/preview/index.d.ts +2 -2
  10. package/dist/preview/index.js +2 -2
  11. package/dist/styles.css +182 -0
  12. package/package.json +2 -2
  13. package/src/AudioRecorderButton.tsx +4 -28
  14. package/src/audioRecorderFormat.ts +27 -0
  15. package/src/participant/ConversationThread.tsx +4 -0
  16. package/src/participant/ParticipantAttachmentItem.tsx +7 -10
  17. package/src/participant/ParticipantAudioAttachment.tsx +34 -0
  18. package/src/participant/ParticipantAudioPlayer.tsx +68 -0
  19. package/src/participant/ParticipantAudioPlayerControls.tsx +68 -0
  20. package/src/participant/ParticipantAudioRecorderView.tsx +55 -0
  21. package/src/participant/ParticipantAudioTranscription.tsx +90 -0
  22. package/src/participant/ParticipantComposer.tsx +50 -15
  23. package/src/participant/ParticipantComposerAudio.tsx +95 -0
  24. package/src/participant/ParticipantConversations.tsx +3 -0
  25. package/src/participant/ParticipantRecorderParts.tsx +118 -0
  26. package/src/participant/ParticipantThread.tsx +7 -0
  27. package/src/participant/ParticipantThreadScreen.tsx +1 -0
  28. package/src/participant/index.ts +3 -0
  29. package/src/participant/participantAudio.types.ts +10 -0
  30. package/src/participant/participantAudioCapture.test.ts +15 -0
  31. package/src/participant/participantAudioCapture.ts +85 -0
  32. package/src/participant/participantAudioCoordinator.ts +13 -0
  33. package/src/participant/participantAudioFormat.test.ts +93 -0
  34. package/src/participant/participantAudioFormat.ts +72 -0
  35. package/src/participant/participantAudioGolden.test.tsx +168 -0
  36. package/src/participant/participantAudioLabels.test.ts +22 -0
  37. package/src/participant/participantAudioLabels.ts +76 -0
  38. package/src/participant/participantAudioPlayerControlsDom.test.tsx +123 -0
  39. package/src/participant/participantAudioPlayerCss.test.ts +63 -0
  40. package/src/participant/participantAudioPlayerDom.test.tsx +154 -0
  41. package/src/participant/participantAudioPlayerDomKit.test-helper.tsx +84 -0
  42. package/src/participant/participantAudioPlayerState.ts +35 -0
  43. package/src/participant/participantAudioRecorderDom.test.tsx +127 -0
  44. package/src/participant/participantAudioRecorderInterruptionDom.test.tsx +135 -0
  45. package/src/participant/participantAudioRecorderSpecDom.test.tsx +110 -0
  46. package/src/participant/participantAudioRecorderState.test.ts +144 -0
  47. package/src/participant/participantAudioRecorderState.ts +93 -0
  48. package/src/participant/participantAudioRecorderView.test.tsx +98 -0
  49. package/src/participant/participantAudioSupport.ts +36 -0
  50. package/src/participant/participantAudioTranscriptionDom.test.tsx +198 -0
  51. package/src/participant/participantComposerAudioDom.test.tsx +185 -0
  52. package/src/participant/participantComposerAudioGolden.test.tsx +68 -0
  53. package/src/participant/participantComposerAudioSsr.test.tsx +74 -0
  54. package/src/participant/participantFileValidation.ts +1 -1
  55. package/src/participant/participantGolden.test.tsx +2 -0
  56. package/src/participant/participantGoldenScenarios.test-helper.tsx +14 -1
  57. package/src/participant/participantLabels.ts +4 -1
  58. package/src/participant/participantMediaFakes.test-helper.ts +82 -0
  59. package/src/participant/participantNoMicrophoneDom.test.tsx +44 -0
  60. package/src/participant/participantPublicTypes.test.ts +11 -0
  61. package/src/participant/participantRecorderDomKit.test-helper.tsx +131 -0
  62. package/src/participant/participantRecorderFakes.test-helper.ts +47 -0
  63. package/src/participant/participantSpecRecorderFake.test-helper.ts +52 -0
  64. package/src/participant/participantThemeContrast.test.ts +9 -0
  65. package/src/participant/test-fixtures/composer-audio-denied.golden.html +1 -0
  66. package/src/participant/test-fixtures/composer-audio-idle.golden.html +1 -0
  67. package/src/participant/test-fixtures/composer-audio-recording.golden.html +1 -0
  68. package/src/participant/test-fixtures/composer-audio-review.golden.html +1 -0
  69. package/src/participant/test-fixtures/thread-audio-native.golden.html +1 -0
  70. package/src/participant/test-fixtures/thread-audio-player.golden.html +1 -0
  71. package/src/participant/test-fixtures/thread-audio-transcription-empty.golden.html +1 -0
  72. package/src/participant/test-fixtures/thread-audio-transcription-pending.golden.html +1 -0
  73. package/src/participant/test-fixtures/thread-audio-transcription-ready.golden.html +1 -0
  74. package/src/participant/test-fixtures/thread-audio-transcription-too_long.golden.html +1 -0
  75. package/src/participant/test-fixtures/thread-composer-microphone-idle.golden.html +1 -0
  76. package/src/participant/test-fixtures/thread-empty-draft.golden.html +1 -0
  77. package/src/participant/useParticipantAudioMediaEvents.ts +46 -0
  78. package/src/participant/useParticipantAudioPlayer.ts +52 -0
  79. package/src/participant/useParticipantAudioPlayerActions.ts +47 -0
  80. package/src/participant/useParticipantAudioRecorder.ts +153 -0
  81. package/src/participant/useParticipantAudioRecorderCore.ts +55 -0
  82. package/src/participant/useParticipantAudioRecorderEffects.ts +70 -0
  83. package/src/participant/useParticipantAudioSourceRetry.ts +42 -0
  84. package/src/participant/useParticipantSendAudio.test.tsx +85 -0
  85. package/src/participant/useParticipantSendAudio.ts +28 -0
  86. package/src/participant/useParticipantThreadController.ts +20 -7
  87. package/src/participant/useStableAttachmentUrl.ts +71 -0
  88. package/src/participant/useTranscriptionOverflow.ts +25 -0
  89. package/src/participant/useTranscriptionReadyAnnouncement.ts +24 -0
  90. package/src/styles.css +29 -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, channel = 'app', disabled = false, isSending = false } = props
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
- <div className="cv-p-composer__row">
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,8 @@ 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
44
47
  }
45
48
 
46
49
  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
+ }
@@ -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,10 @@ 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
74
79
  }
75
80
 
76
81
  function Footer({ props }: { readonly props: ParticipantThreadProps }) {
@@ -91,6 +96,8 @@ function Footer({ props }: { readonly props: ParticipantThreadProps }) {
91
96
  maxLength={props.maxLength}
92
97
  acceptedTypes={props.acceptedTypes}
93
98
  quickReplies={props.quickReplies}
99
+ audioRecording={props.audioRecording}
100
+ onSendAudio={props.onSendAudio}
94
101
  />
95
102
  )
96
103
  }
@@ -104,6 +104,7 @@ function LoadedThread(props: LoadedThreadProps) {
104
104
  avatars={props.avatars}
105
105
  renderAuthorAvatar={props.renderAuthorAvatar}
106
106
  tail={props.tail}
107
+ audioRecording={props.audioRecording}
107
108
  />
108
109
  )
109
110
  }
@@ -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
+ }