@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,153 @@
|
|
|
1
|
+
import { useCallback, useMemo, type RefObject } from 'react'
|
|
2
|
+
|
|
3
|
+
import { resolveRecordingFormat, type RecordingFormat } from '../audioRecorderFormat'
|
|
4
|
+
import type { ParticipantAudioRecordingOptions } from './participantAudio.types'
|
|
5
|
+
import { buildRecordedFile, openCaptureSession, stopStream } from './participantAudioCapture'
|
|
6
|
+
import {
|
|
7
|
+
canStartRecording,
|
|
8
|
+
classifyCapture,
|
|
9
|
+
classifyMicrophoneError,
|
|
10
|
+
resolveInterruptionAction,
|
|
11
|
+
resolveRecordingLimits,
|
|
12
|
+
type RecorderStatus,
|
|
13
|
+
type RecordingLimits,
|
|
14
|
+
} from './participantAudioRecorderState'
|
|
15
|
+
import { useRecorderCore, type RecorderCore, type RecordedAudio } from './useParticipantAudioRecorderCore'
|
|
16
|
+
import { useRecorderInterruptionListeners, useRecorderLifecycle, useRecordingClock } from './useParticipantAudioRecorderEffects'
|
|
17
|
+
|
|
18
|
+
export type { RecordedAudio }
|
|
19
|
+
|
|
20
|
+
export type UseParticipantAudioRecorderParams = {
|
|
21
|
+
readonly options?: ParticipantAudioRecordingOptions
|
|
22
|
+
readonly channelMaxBytes?: number
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export type ParticipantAudioRecorder = {
|
|
26
|
+
readonly status: RecorderStatus
|
|
27
|
+
readonly elapsedMs: number
|
|
28
|
+
readonly limits: RecordingLimits
|
|
29
|
+
readonly recorded: RecordedAudio | undefined
|
|
30
|
+
readonly audioRef: RefObject<HTMLAudioElement | null>
|
|
31
|
+
readonly start: () => Promise<void>
|
|
32
|
+
readonly stop: () => void
|
|
33
|
+
readonly discard: () => void
|
|
34
|
+
readonly confirm: () => File | undefined
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function useRecorderFinalize(core: RecorderCore): (blob: Blob, durationMs: number) => void {
|
|
38
|
+
const { statusRef, sessionRef, recordedRef, limitsRef, setRecorded, moveTo, releaseSession } = core
|
|
39
|
+
return useCallback(
|
|
40
|
+
(blob: Blob, durationMs: number): void => {
|
|
41
|
+
const format = sessionRef.current?.format
|
|
42
|
+
if (statusRef.current !== 'recording' || !format) return
|
|
43
|
+
releaseSession()
|
|
44
|
+
const event = classifyCapture({ sizeBytes: blob.size, durationMs }, limitsRef.current)
|
|
45
|
+
if (event === 'capturedReview') {
|
|
46
|
+
const file = buildRecordedFile(blob, format)
|
|
47
|
+
recordedRef.current = { file, objectUrl: URL.createObjectURL(file), durationMs }
|
|
48
|
+
setRecorded(recordedRef.current)
|
|
49
|
+
}
|
|
50
|
+
moveTo(event)
|
|
51
|
+
},
|
|
52
|
+
[statusRef, sessionRef, recordedRef, limitsRef, setRecorded, moveTo, releaseSession],
|
|
53
|
+
)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function useRecorderInterruption(core: RecorderCore): () => void {
|
|
57
|
+
const { statusRef, sessionRef, tokenRef, audioRef, moveTo } = core
|
|
58
|
+
return useCallback((): void => {
|
|
59
|
+
const action = resolveInterruptionAction(statusRef.current)
|
|
60
|
+
if (action === 'stop') sessionRef.current?.requestStop()
|
|
61
|
+
if (action === 'pause') audioRef.current?.pause()
|
|
62
|
+
if (action === 'cancel') {
|
|
63
|
+
tokenRef.current += 1
|
|
64
|
+
moveTo('cancel')
|
|
65
|
+
}
|
|
66
|
+
}, [statusRef, sessionRef, tokenRef, audioRef, moveTo])
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
type RecorderStartParams = {
|
|
70
|
+
readonly onInterrupt: () => void
|
|
71
|
+
readonly onStopped: (blob: Blob, durationMs: number) => void
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function useRecorderStart(core: RecorderCore, { onInterrupt, onStopped }: RecorderStartParams): () => Promise<void> {
|
|
75
|
+
const { statusRef, tokenRef, sessionRef, setElapsedMs, moveTo } = core
|
|
76
|
+
const beginRecording = useCallback(
|
|
77
|
+
(stream: MediaStream, format: RecordingFormat): void => {
|
|
78
|
+
try {
|
|
79
|
+
sessionRef.current = openCaptureSession({ stream, format, onInterrupt, onStopped })
|
|
80
|
+
} catch {
|
|
81
|
+
stopStream(stream)
|
|
82
|
+
moveTo('microphoneUnavailable')
|
|
83
|
+
return
|
|
84
|
+
}
|
|
85
|
+
setElapsedMs(0)
|
|
86
|
+
moveTo('granted')
|
|
87
|
+
},
|
|
88
|
+
[sessionRef, setElapsedMs, onInterrupt, onStopped, moveTo],
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
return useCallback(async (): Promise<void> => {
|
|
92
|
+
if (!canStartRecording(statusRef.current)) return
|
|
93
|
+
const token = (tokenRef.current += 1)
|
|
94
|
+
moveTo('request')
|
|
95
|
+
const format = resolveRecordingFormat()
|
|
96
|
+
if (!format || !navigator.mediaDevices?.getUserMedia) return moveTo('microphoneUnavailable')
|
|
97
|
+
let stream: MediaStream
|
|
98
|
+
try {
|
|
99
|
+
stream = await navigator.mediaDevices.getUserMedia({ audio: true })
|
|
100
|
+
} catch (error) {
|
|
101
|
+
if (token === tokenRef.current) moveTo(classifyMicrophoneError(error))
|
|
102
|
+
return
|
|
103
|
+
}
|
|
104
|
+
if (token !== tokenRef.current) return stopStream(stream)
|
|
105
|
+
beginRecording(stream, format)
|
|
106
|
+
}, [statusRef, tokenRef, moveTo, beginRecording])
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
type RecorderActions = Pick<ParticipantAudioRecorder, 'stop' | 'discard' | 'confirm'> & { readonly teardown: () => void }
|
|
110
|
+
|
|
111
|
+
function useRecorderActions(core: RecorderCore): RecorderActions {
|
|
112
|
+
const { statusRef, tokenRef, sessionRef, recordedRef, moveTo, releaseSession, releaseRecorded } = core
|
|
113
|
+
const stop = useCallback((): void => sessionRef.current?.requestStop(), [sessionRef])
|
|
114
|
+
const discard = useCallback((): void => {
|
|
115
|
+
tokenRef.current += 1
|
|
116
|
+
releaseSession()
|
|
117
|
+
releaseRecorded()
|
|
118
|
+
moveTo('cancel')
|
|
119
|
+
}, [tokenRef, moveTo, releaseRecorded, releaseSession])
|
|
120
|
+
const confirm = useCallback((): File | undefined => {
|
|
121
|
+
if (statusRef.current !== 'review') return undefined
|
|
122
|
+
const file = recordedRef.current?.file
|
|
123
|
+
releaseRecorded()
|
|
124
|
+
moveTo('cancel')
|
|
125
|
+
return file
|
|
126
|
+
}, [statusRef, recordedRef, moveTo, releaseRecorded])
|
|
127
|
+
const teardown = useCallback((): void => {
|
|
128
|
+
tokenRef.current += 1
|
|
129
|
+
releaseSession()
|
|
130
|
+
if (recordedRef.current) URL.revokeObjectURL(recordedRef.current.objectUrl)
|
|
131
|
+
recordedRef.current = undefined
|
|
132
|
+
}, [tokenRef, recordedRef, releaseSession])
|
|
133
|
+
return { stop, discard, confirm, teardown }
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function useParticipantAudioRecorder({ options, channelMaxBytes }: UseParticipantAudioRecorderParams = {}): ParticipantAudioRecorder {
|
|
137
|
+
const limits = useMemo(
|
|
138
|
+
() => resolveRecordingLimits({ options, channelMaxBytes }),
|
|
139
|
+
[options?.maxDurationMs, options?.maxSizeBytes, channelMaxBytes],
|
|
140
|
+
)
|
|
141
|
+
const core = useRecorderCore(limits)
|
|
142
|
+
const onInterrupt = useRecorderInterruption(core)
|
|
143
|
+
const onStopped = useRecorderFinalize(core)
|
|
144
|
+
const start = useRecorderStart(core, { onInterrupt, onStopped })
|
|
145
|
+
const { stop, discard, confirm, teardown } = useRecorderActions(core)
|
|
146
|
+
|
|
147
|
+
useRecorderInterruptionListeners(onInterrupt)
|
|
148
|
+
useRecordingClock({ status: core.status, maxDurationMs: limits.maxDurationMs, sessionRef: core.sessionRef, setElapsedMs: core.setElapsedMs, stop })
|
|
149
|
+
useRecorderLifecycle({ status: core.status, onCaptureChange: options?.onCaptureChange, teardown })
|
|
150
|
+
|
|
151
|
+
const { status, elapsedMs, recorded, audioRef } = core
|
|
152
|
+
return { status, elapsedMs, limits, recorded, audioRef, start, stop, discard, confirm }
|
|
153
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { useCallback, useRef, useState, type Dispatch, type RefObject, type SetStateAction } from 'react'
|
|
2
|
+
|
|
3
|
+
import type { CaptureSession } from './participantAudioCapture'
|
|
4
|
+
import { reduceRecorderStatus, type RecorderEvent, type RecorderStatus, type RecordingLimits } from './participantAudioRecorderState'
|
|
5
|
+
|
|
6
|
+
export type RecordedAudio = { readonly file: File; readonly objectUrl: string; readonly durationMs: number }
|
|
7
|
+
|
|
8
|
+
export type RecorderCore = {
|
|
9
|
+
readonly status: RecorderStatus
|
|
10
|
+
readonly elapsedMs: number
|
|
11
|
+
readonly setElapsedMs: Dispatch<SetStateAction<number>>
|
|
12
|
+
readonly recorded: RecordedAudio | undefined
|
|
13
|
+
readonly setRecorded: (recorded: RecordedAudio | undefined) => void
|
|
14
|
+
readonly limitsRef: RefObject<RecordingLimits>
|
|
15
|
+
readonly statusRef: RefObject<RecorderStatus>
|
|
16
|
+
readonly tokenRef: RefObject<number>
|
|
17
|
+
readonly sessionRef: RefObject<CaptureSession | undefined>
|
|
18
|
+
readonly recordedRef: RefObject<RecordedAudio | undefined>
|
|
19
|
+
readonly audioRef: RefObject<HTMLAudioElement | null>
|
|
20
|
+
readonly moveTo: (event: RecorderEvent) => void
|
|
21
|
+
readonly releaseSession: () => void
|
|
22
|
+
readonly releaseRecorded: () => void
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function useRecorderCore(limits: RecordingLimits): RecorderCore {
|
|
26
|
+
const [status, setStatus] = useState<RecorderStatus>('idle')
|
|
27
|
+
const [elapsedMs, setElapsedMs] = useState(0)
|
|
28
|
+
const [recorded, setRecorded] = useState<RecordedAudio | undefined>()
|
|
29
|
+
const statusRef = useRef<RecorderStatus>('idle')
|
|
30
|
+
const limitsRef = useRef(limits)
|
|
31
|
+
const tokenRef = useRef(0)
|
|
32
|
+
const sessionRef = useRef<CaptureSession | undefined>(undefined)
|
|
33
|
+
const recordedRef = useRef<RecordedAudio | undefined>(undefined)
|
|
34
|
+
const audioRef = useRef<HTMLAudioElement | null>(null)
|
|
35
|
+
limitsRef.current = limits
|
|
36
|
+
|
|
37
|
+
const moveTo = useCallback((event: RecorderEvent): void => {
|
|
38
|
+
statusRef.current = reduceRecorderStatus(statusRef.current, event)
|
|
39
|
+
setStatus(statusRef.current)
|
|
40
|
+
}, [])
|
|
41
|
+
const releaseSession = useCallback((): void => {
|
|
42
|
+
sessionRef.current?.release()
|
|
43
|
+
sessionRef.current = undefined
|
|
44
|
+
}, [])
|
|
45
|
+
const releaseRecorded = useCallback((): void => {
|
|
46
|
+
if (recordedRef.current) URL.revokeObjectURL(recordedRef.current.objectUrl)
|
|
47
|
+
recordedRef.current = undefined
|
|
48
|
+
setRecorded(undefined)
|
|
49
|
+
}, [])
|
|
50
|
+
|
|
51
|
+
return {
|
|
52
|
+
status, elapsedMs, setElapsedMs, recorded, setRecorded, limitsRef, statusRef, tokenRef,
|
|
53
|
+
sessionRef, recordedRef, audioRef, moveTo, releaseSession, releaseRecorded,
|
|
54
|
+
}
|
|
55
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { useEffect, useRef, type Dispatch, type MutableRefObject, type SetStateAction } from 'react'
|
|
2
|
+
|
|
3
|
+
import type { CaptureSession } from './participantAudioCapture'
|
|
4
|
+
import type { RecorderStatus } from './participantAudioRecorderState'
|
|
5
|
+
|
|
6
|
+
const ELAPSED_TICK_MS = 250
|
|
7
|
+
|
|
8
|
+
export function useRecorderInterruptionListeners(onInterruption: () => void): void {
|
|
9
|
+
useEffect(() => {
|
|
10
|
+
const handleVisibilityChange = (): void => {
|
|
11
|
+
if (document.visibilityState === 'hidden') onInterruption()
|
|
12
|
+
}
|
|
13
|
+
document.addEventListener('visibilitychange', handleVisibilityChange)
|
|
14
|
+
window.addEventListener('pagehide', onInterruption)
|
|
15
|
+
return () => {
|
|
16
|
+
document.removeEventListener('visibilitychange', handleVisibilityChange)
|
|
17
|
+
window.removeEventListener('pagehide', onInterruption)
|
|
18
|
+
}
|
|
19
|
+
}, [onInterruption])
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
type RecordingClockParams = {
|
|
23
|
+
readonly status: RecorderStatus
|
|
24
|
+
readonly maxDurationMs: number
|
|
25
|
+
readonly sessionRef: MutableRefObject<CaptureSession | undefined>
|
|
26
|
+
readonly setElapsedMs: Dispatch<SetStateAction<number>>
|
|
27
|
+
readonly stop: () => void
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function useRecordingClock({ status, maxDurationMs, sessionRef, setElapsedMs, stop }: RecordingClockParams): void {
|
|
31
|
+
useEffect(() => {
|
|
32
|
+
if (status !== 'recording') return undefined
|
|
33
|
+
const startedAt = sessionRef.current?.startedAt ?? Date.now()
|
|
34
|
+
const tick = setInterval(() => setElapsedMs(Date.now() - startedAt), ELAPSED_TICK_MS)
|
|
35
|
+
const ceiling = setTimeout(stop, maxDurationMs)
|
|
36
|
+
return () => {
|
|
37
|
+
clearInterval(tick)
|
|
38
|
+
clearTimeout(ceiling)
|
|
39
|
+
}
|
|
40
|
+
}, [status, maxDurationMs, sessionRef, setElapsedMs, stop])
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
type RecorderLifecycleParams = {
|
|
44
|
+
readonly status: RecorderStatus
|
|
45
|
+
readonly onCaptureChange: ((isCapturing: boolean) => void) | undefined
|
|
46
|
+
readonly teardown: () => void
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function useRecorderLifecycle({ status, onCaptureChange, teardown }: RecorderLifecycleParams): void {
|
|
50
|
+
const isCapturing = status === 'requesting' || status === 'recording' || status === 'review'
|
|
51
|
+
const reportedRef = useRef(false)
|
|
52
|
+
const onCaptureChangeRef = useRef(onCaptureChange)
|
|
53
|
+
onCaptureChangeRef.current = onCaptureChange
|
|
54
|
+
|
|
55
|
+
useEffect(() => {
|
|
56
|
+
if (reportedRef.current === isCapturing) return
|
|
57
|
+
reportedRef.current = isCapturing
|
|
58
|
+
onCaptureChangeRef.current?.(isCapturing)
|
|
59
|
+
}, [isCapturing])
|
|
60
|
+
|
|
61
|
+
useEffect(
|
|
62
|
+
() => () => {
|
|
63
|
+
teardown()
|
|
64
|
+
if (!reportedRef.current) return
|
|
65
|
+
reportedRef.current = false
|
|
66
|
+
onCaptureChangeRef.current?.(false)
|
|
67
|
+
},
|
|
68
|
+
[teardown],
|
|
69
|
+
)
|
|
70
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { useEffect, useRef, type SyntheticEvent } from 'react'
|
|
2
|
+
|
|
3
|
+
import type { PlayerContext } from './participantAudioPlayerState'
|
|
4
|
+
|
|
5
|
+
export type UseParticipantAudioSourceRetryParams = {
|
|
6
|
+
readonly context: PlayerContext
|
|
7
|
+
readonly url: string | undefined
|
|
8
|
+
readonly onSourceError: (() => Promise<string | undefined>) | undefined
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** One retry with a fresh URL when the signed one expired, resuming from the same position. */
|
|
12
|
+
export function useParticipantAudioSourceRetry({ context, url, onSourceError }: UseParticipantAudioSourceRetryParams) {
|
|
13
|
+
const { audioRef, update } = context
|
|
14
|
+
const canRetryRef = useRef(true)
|
|
15
|
+
const resumeAtRef = useRef<number | undefined>(undefined)
|
|
16
|
+
|
|
17
|
+
useEffect(() => {
|
|
18
|
+
const audio = audioRef.current
|
|
19
|
+
const resumeAt = resumeAtRef.current
|
|
20
|
+
if (!audio || resumeAt === undefined || !url) return
|
|
21
|
+
resumeAtRef.current = undefined
|
|
22
|
+
audio.currentTime = resumeAt
|
|
23
|
+
Promise.resolve(audio.play()).catch(() => update({ isPlaying: false }))
|
|
24
|
+
}, [url, audioRef, update])
|
|
25
|
+
|
|
26
|
+
function allowRetry(): void {
|
|
27
|
+
canRetryRef.current = true
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function handleError(event: SyntheticEvent<HTMLAudioElement>): void {
|
|
31
|
+
if (!onSourceError || !canRetryRef.current) return update({ hasFailed: true, isPlaying: false })
|
|
32
|
+
canRetryRef.current = false
|
|
33
|
+
resumeAtRef.current = event.currentTarget.currentTime
|
|
34
|
+
onSourceError().then((fresh) => {
|
|
35
|
+
if (fresh && fresh !== url) return
|
|
36
|
+
resumeAtRef.current = undefined
|
|
37
|
+
update({ hasFailed: true, isPlaying: false })
|
|
38
|
+
})
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return { handleError, allowRetry }
|
|
42
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { act } 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 type { ParticipantSendContent } from './participantDrafts'
|
|
7
|
+
import { useParticipantSendAudio } from './useParticipantSendAudio'
|
|
8
|
+
|
|
9
|
+
let harness: DomHarness
|
|
10
|
+
let sendAudio: (file: File) => void
|
|
11
|
+
let calls: ParticipantSendContent[]
|
|
12
|
+
let releases: Array<() => void>
|
|
13
|
+
|
|
14
|
+
function Probe() {
|
|
15
|
+
sendAudio = useParticipantSendAudio((content) => {
|
|
16
|
+
calls.push(content)
|
|
17
|
+
return new Promise<void>((resolve) => releases.push(resolve))
|
|
18
|
+
})
|
|
19
|
+
return null
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
beforeAll(async () => {
|
|
23
|
+
harness = await setupDom()
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
afterAll(() => harness.teardown())
|
|
27
|
+
|
|
28
|
+
function mountProbe(): void {
|
|
29
|
+
calls = []
|
|
30
|
+
releases = []
|
|
31
|
+
harness.mount(<Probe />)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const audio = (name: string) => new File(['x'], name, { type: 'audio/ogg' })
|
|
35
|
+
|
|
36
|
+
describe('useParticipantSendAudio', () => {
|
|
37
|
+
it('sends the file alone, as a message of its own', () => {
|
|
38
|
+
mountProbe()
|
|
39
|
+
const file = audio('a.ogg')
|
|
40
|
+
sendAudio(file)
|
|
41
|
+
expect(calls).toEqual([{ files: [file] }])
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it('ignores a second synchronous send of the same file', () => {
|
|
45
|
+
mountProbe()
|
|
46
|
+
const file = audio('a.ogg')
|
|
47
|
+
sendAudio(file)
|
|
48
|
+
sendAudio(file)
|
|
49
|
+
expect(calls).toHaveLength(1)
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('lets another recording through while the first is still uploading, and the same one again once it settled', async () => {
|
|
53
|
+
mountProbe()
|
|
54
|
+
const first = audio('a.ogg')
|
|
55
|
+
sendAudio(first)
|
|
56
|
+
sendAudio(audio('b.ogg'))
|
|
57
|
+
expect(calls).toHaveLength(2)
|
|
58
|
+
await act(async () => releases[0]?.())
|
|
59
|
+
sendAudio(first)
|
|
60
|
+
expect(calls).toHaveLength(3)
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('a send that throws (for example randomUUID in an insecure context) is contained and frees the file for a retry', async () => {
|
|
64
|
+
const unhandled: unknown[] = []
|
|
65
|
+
const onUnhandled = (reason: unknown): void => void unhandled.push(reason)
|
|
66
|
+
process.on('unhandledRejection', onUnhandled)
|
|
67
|
+
let attempts = 0
|
|
68
|
+
function Throwing() {
|
|
69
|
+
sendAudio = useParticipantSendAudio(async () => {
|
|
70
|
+
attempts += 1
|
|
71
|
+
throw new Error('crypto.randomUUID is not a function')
|
|
72
|
+
})
|
|
73
|
+
return null
|
|
74
|
+
}
|
|
75
|
+
harness.mount(<Throwing />)
|
|
76
|
+
const file = audio('a.ogg')
|
|
77
|
+
sendAudio(file)
|
|
78
|
+
await new Promise((resolve) => setTimeout(resolve, 10))
|
|
79
|
+
sendAudio(file)
|
|
80
|
+
await new Promise((resolve) => setTimeout(resolve, 10))
|
|
81
|
+
process.off('unhandledRejection', onUnhandled)
|
|
82
|
+
expect(unhandled).toEqual([])
|
|
83
|
+
expect(attempts).toBe(2)
|
|
84
|
+
})
|
|
85
|
+
})
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { useCallback, useRef } from 'react'
|
|
2
|
+
|
|
3
|
+
import type { ParticipantSendContent } from './participantDrafts'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Sends a recorded audio as its own message through the attachment path. The file itself is the
|
|
7
|
+
* lock: send generates a new client id per call, so the server idempotency would not stop a double tap.
|
|
8
|
+
*/
|
|
9
|
+
export function useParticipantSendAudio(send: (content: ParticipantSendContent) => Promise<unknown>): (file: File) => void {
|
|
10
|
+
const sendRef = useRef(send)
|
|
11
|
+
sendRef.current = send
|
|
12
|
+
const inFlightRef = useRef(new Set<File>())
|
|
13
|
+
|
|
14
|
+
return useCallback((file: File): void => {
|
|
15
|
+
const inFlight = inFlightRef.current
|
|
16
|
+
if (inFlight.has(file)) return
|
|
17
|
+
inFlight.add(file)
|
|
18
|
+
void (async () => {
|
|
19
|
+
try {
|
|
20
|
+
await sendRef.current({ files: [file] })
|
|
21
|
+
} catch {
|
|
22
|
+
// the send path reports its own failures; a throw before it (insecure context) must not become an unhandled rejection
|
|
23
|
+
} finally {
|
|
24
|
+
inFlight.delete(file)
|
|
25
|
+
}
|
|
26
|
+
})()
|
|
27
|
+
}, [])
|
|
28
|
+
}
|
|
@@ -13,6 +13,7 @@ import { findFailedEntry, hasSendingEntry, type ParticipantSendAction } from './
|
|
|
13
13
|
import type { ParticipantSendStates, ParticipantSendStatesAction } from './participantSendStates'
|
|
14
14
|
import { useParticipantConversationView } from './useParticipantConversationView'
|
|
15
15
|
import { useParticipantDraft } from './useParticipantDraft'
|
|
16
|
+
import { useParticipantSendAudio } from './useParticipantSendAudio'
|
|
16
17
|
import { useParticipantThread, type UseParticipantThreadResult } from './useParticipantThread'
|
|
17
18
|
import { useParticipantThreadScroll } from './useParticipantThreadScroll'
|
|
18
19
|
|
|
@@ -53,6 +54,7 @@ export type ParticipantThreadCoreProps = Pick<
|
|
|
53
54
|
| 'onLoadOlder'
|
|
54
55
|
| 'pendingActions'
|
|
55
56
|
| 'perspective'
|
|
57
|
+
| 'onSendAudio'
|
|
56
58
|
>
|
|
57
59
|
|
|
58
60
|
type BuildPendingActionsParams = {
|
|
@@ -71,25 +73,35 @@ function buildPendingActions({ thread, dispatchSend, handleEdit, onRetryPending
|
|
|
71
73
|
}
|
|
72
74
|
}
|
|
73
75
|
|
|
76
|
+
type BuildEditHandlerParams = {
|
|
77
|
+
readonly sendEntries: Parameters<typeof findFailedEntry>[0]
|
|
78
|
+
readonly dispatchSend: (action: ParticipantSendAction) => void
|
|
79
|
+
readonly restoreFromEntry: (entry: NonNullable<ReturnType<typeof findFailedEntry>>) => void
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function buildEditHandler({ sendEntries, dispatchSend, restoreFromEntry }: BuildEditHandlerParams): (clientMessageId: string) => void {
|
|
83
|
+
return (clientMessageId) => {
|
|
84
|
+
const entry = findFailedEntry(sendEntries, clientMessageId)
|
|
85
|
+
if (!entry) return
|
|
86
|
+
restoreFromEntry(entry)
|
|
87
|
+
dispatchSend({ type: 'discarded', clientMessageId })
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
74
91
|
export function useParticipantThreadController(params: ParticipantThreadControllerParams): ParticipantThreadCoreProps {
|
|
75
92
|
const { api, subject, conversation, draftsRef } = params
|
|
76
93
|
const { draft, setText, setFiles, takeForSend, restoreFromEntry } = useParticipantDraft(draftsRef, subject)
|
|
77
94
|
const { thread, items, sendEntries, dispatchSend } = useParticipantConversationView(params)
|
|
78
95
|
const resolveAttachmentUrl = useMemo(() => bindAttachmentUrlResolver(api), [api])
|
|
79
96
|
const { scroll, newMessagesCount } = useParticipantThreadScroll(items, params.perspective)
|
|
97
|
+
const handleSendAudio = useParticipantSendAudio(thread.send)
|
|
98
|
+
const handleEdit = buildEditHandler({ sendEntries, dispatchSend, restoreFromEntry })
|
|
80
99
|
|
|
81
100
|
function handleSend(): void {
|
|
82
101
|
const content = takeForSend()
|
|
83
102
|
if (content) void thread.send(content)
|
|
84
103
|
}
|
|
85
104
|
|
|
86
|
-
function handleEdit(clientMessageId: string): void {
|
|
87
|
-
const entry = findFailedEntry(sendEntries, clientMessageId)
|
|
88
|
-
if (!entry) return
|
|
89
|
-
restoreFromEntry(entry)
|
|
90
|
-
dispatchSend({ type: 'discarded', clientMessageId })
|
|
91
|
-
}
|
|
92
|
-
|
|
93
105
|
return {
|
|
94
106
|
conversation,
|
|
95
107
|
items,
|
|
@@ -108,5 +120,6 @@ export function useParticipantThreadController(params: ParticipantThreadControll
|
|
|
108
120
|
onLoadOlder: () => void thread.loadOlder(),
|
|
109
121
|
pendingActions: buildPendingActions({ thread, dispatchSend, handleEdit, onRetryPending: params.onRetryPending }),
|
|
110
122
|
perspective: params.perspective,
|
|
123
|
+
onSendAudio: handleSendAudio,
|
|
111
124
|
}
|
|
112
125
|
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef, useState } from 'react'
|
|
2
|
+
|
|
3
|
+
import type { ParticipantAttachment } from '@adatechnology/conversation-contracts'
|
|
4
|
+
|
|
5
|
+
import type { ResolveParticipantAttachmentUrl } from './ParticipantAttachmentItem'
|
|
6
|
+
import { isSafeAttachmentUrl } from './participantAttachmentUrl'
|
|
7
|
+
|
|
8
|
+
export type StableAttachmentUrl = {
|
|
9
|
+
readonly status: 'pending' | 'ready' | 'failed'
|
|
10
|
+
readonly url: string | undefined
|
|
11
|
+
/** Asks the host for a fresh URL (the signed one expired); resolves undefined when it cannot. */
|
|
12
|
+
readonly resolveAgain: () => Promise<string | undefined>
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export type StableAttachmentUrlParams = {
|
|
16
|
+
readonly attachment: ParticipantAttachment
|
|
17
|
+
readonly resolveAttachmentUrl: ResolveParticipantAttachmentUrl
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
type ResolvedState = Pick<StableAttachmentUrl, 'status' | 'url'>
|
|
21
|
+
|
|
22
|
+
export const ATTACHMENT_URL_RETRY_DELAY_MS = 1000
|
|
23
|
+
|
|
24
|
+
const PENDING_STATE: ResolvedState = { status: 'pending', url: undefined }
|
|
25
|
+
|
|
26
|
+
async function resolveSafely({ attachment, resolveAttachmentUrl }: StableAttachmentUrlParams): Promise<string | undefined> {
|
|
27
|
+
try {
|
|
28
|
+
const resolved = await resolveAttachmentUrl(attachment, 'inline')
|
|
29
|
+
return isSafeAttachmentUrl(resolved) ? resolved.trim() : undefined
|
|
30
|
+
} catch {
|
|
31
|
+
return undefined
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Resolves once per `attachment.id`: a refreshed conversation hands over a new object and URL, and swapping `src` restarts the audio. */
|
|
36
|
+
export function useStableAttachmentUrl(params: StableAttachmentUrlParams): StableAttachmentUrl {
|
|
37
|
+
const latest = useRef(params)
|
|
38
|
+
const currentId = useRef(params.attachment.id)
|
|
39
|
+
const [state, setState] = useState<ResolvedState>(PENDING_STATE)
|
|
40
|
+
const { id } = params.attachment
|
|
41
|
+
|
|
42
|
+
useEffect(() => {
|
|
43
|
+
latest.current = params
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
useEffect(() => {
|
|
47
|
+
currentId.current = id
|
|
48
|
+
setState(PENDING_STATE)
|
|
49
|
+
let retryTimer: ReturnType<typeof setTimeout> | undefined
|
|
50
|
+
const settle = (url: string | undefined): void => {
|
|
51
|
+
if (currentId.current === id) setState(url ? { status: 'ready', url } : { status: 'failed', url: undefined })
|
|
52
|
+
}
|
|
53
|
+
resolveSafely(latest.current).then((url) => {
|
|
54
|
+
if (url || currentId.current !== id) return settle(url)
|
|
55
|
+
retryTimer = setTimeout(() => void resolveSafely(latest.current).then(settle), ATTACHMENT_URL_RETRY_DELAY_MS)
|
|
56
|
+
})
|
|
57
|
+
return () => {
|
|
58
|
+
currentId.current = ''
|
|
59
|
+
clearTimeout(retryTimer)
|
|
60
|
+
}
|
|
61
|
+
}, [id])
|
|
62
|
+
|
|
63
|
+
const resolveAgain = useCallback(async (): Promise<string | undefined> => {
|
|
64
|
+
const requestedId = currentId.current
|
|
65
|
+
const url = await resolveSafely(latest.current)
|
|
66
|
+
if (url && currentId.current === requestedId) setState({ status: 'ready', url })
|
|
67
|
+
return url
|
|
68
|
+
}, [])
|
|
69
|
+
|
|
70
|
+
return { ...state, resolveAgain }
|
|
71
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { useEffect, useRef, useState, type RefObject } from 'react'
|
|
2
|
+
|
|
3
|
+
export type TranscriptionOverflow = {
|
|
4
|
+
readonly textRef: RefObject<HTMLParagraphElement | null>
|
|
5
|
+
readonly isOverflowing: boolean
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/** Measures the clamped paragraph; nothing is claimed before layout exists (server render, first paint). */
|
|
9
|
+
export function useTranscriptionOverflow(text: string, isExpanded: boolean): TranscriptionOverflow {
|
|
10
|
+
const textRef = useRef<HTMLParagraphElement | null>(null)
|
|
11
|
+
const [isOverflowing, setIsOverflowing] = useState(false)
|
|
12
|
+
|
|
13
|
+
useEffect(() => {
|
|
14
|
+
const element = textRef.current
|
|
15
|
+
if (!element || isExpanded) return undefined
|
|
16
|
+
const measure = (): void => setIsOverflowing(element.scrollHeight > element.clientHeight)
|
|
17
|
+
measure()
|
|
18
|
+
if (typeof ResizeObserver === 'undefined') return undefined
|
|
19
|
+
const observer = new ResizeObserver(measure)
|
|
20
|
+
observer.observe(element)
|
|
21
|
+
return () => observer.disconnect()
|
|
22
|
+
}, [text, isExpanded])
|
|
23
|
+
|
|
24
|
+
return { textRef, isOverflowing }
|
|
25
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { useEffect, useRef } from 'react'
|
|
2
|
+
|
|
3
|
+
import type { ParticipantTranscription } from '@adatechnology/conversation-contracts'
|
|
4
|
+
|
|
5
|
+
import { useAnnounceCopy } from './ParticipantCopyAnnouncer'
|
|
6
|
+
|
|
7
|
+
export type TranscriptionReadyAnnouncementParams = {
|
|
8
|
+
readonly attachmentId: string
|
|
9
|
+
readonly status: ParticipantTranscription['status'] | undefined
|
|
10
|
+
readonly text: string
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Speaks through the thread's existing live region, only for a pending to ready change seen by this mount. */
|
|
14
|
+
export function useTranscriptionReadyAnnouncement({ attachmentId, status, text }: TranscriptionReadyAnnouncementParams): void {
|
|
15
|
+
const announce = useAnnounceCopy()
|
|
16
|
+
const previous = useRef({ attachmentId, status })
|
|
17
|
+
|
|
18
|
+
useEffect(() => {
|
|
19
|
+
const before = previous.current
|
|
20
|
+
previous.current = { attachmentId, status }
|
|
21
|
+
if (before.attachmentId !== attachmentId) return
|
|
22
|
+
if (before.status === 'pending' && status === 'ready') announce(text)
|
|
23
|
+
}, [announce, attachmentId, status, text])
|
|
24
|
+
}
|