@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.
Files changed (91) hide show
  1. package/README.md +107 -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 +55 -3
  8. package/dist/participant/index.js +1369 -319
  9. package/dist/preview/index.d.ts +2 -2
  10. package/dist/preview/index.js +2 -2
  11. package/dist/styles.css +187 -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 +7 -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 +5 -0
  25. package/src/participant/ParticipantRecorderParts.tsx +118 -0
  26. package/src/participant/ParticipantThread.test.tsx +46 -0
  27. package/src/participant/ParticipantThread.tsx +17 -0
  28. package/src/participant/ParticipantThreadScreen.tsx +2 -0
  29. package/src/participant/index.ts +3 -0
  30. package/src/participant/participantAudio.types.ts +10 -0
  31. package/src/participant/participantAudioCapture.test.ts +15 -0
  32. package/src/participant/participantAudioCapture.ts +85 -0
  33. package/src/participant/participantAudioCoordinator.ts +13 -0
  34. package/src/participant/participantAudioFormat.test.ts +93 -0
  35. package/src/participant/participantAudioFormat.ts +72 -0
  36. package/src/participant/participantAudioGolden.test.tsx +168 -0
  37. package/src/participant/participantAudioLabels.test.ts +22 -0
  38. package/src/participant/participantAudioLabels.ts +76 -0
  39. package/src/participant/participantAudioPlayerControlsDom.test.tsx +123 -0
  40. package/src/participant/participantAudioPlayerCss.test.ts +63 -0
  41. package/src/participant/participantAudioPlayerDom.test.tsx +154 -0
  42. package/src/participant/participantAudioPlayerDomKit.test-helper.tsx +84 -0
  43. package/src/participant/participantAudioPlayerState.ts +35 -0
  44. package/src/participant/participantAudioRecorderDom.test.tsx +127 -0
  45. package/src/participant/participantAudioRecorderInterruptionDom.test.tsx +135 -0
  46. package/src/participant/participantAudioRecorderSpecDom.test.tsx +110 -0
  47. package/src/participant/participantAudioRecorderState.test.ts +144 -0
  48. package/src/participant/participantAudioRecorderState.ts +93 -0
  49. package/src/participant/participantAudioRecorderView.test.tsx +98 -0
  50. package/src/participant/participantAudioSupport.ts +36 -0
  51. package/src/participant/participantAudioTranscriptionDom.test.tsx +198 -0
  52. package/src/participant/participantComposerAudioDom.test.tsx +185 -0
  53. package/src/participant/participantComposerAudioGolden.test.tsx +68 -0
  54. package/src/participant/participantComposerAudioSsr.test.tsx +74 -0
  55. package/src/participant/participantFileValidation.ts +1 -1
  56. package/src/participant/participantGolden.test.tsx +2 -0
  57. package/src/participant/participantGoldenScenarios.test-helper.tsx +14 -1
  58. package/src/participant/participantLabels.ts +4 -1
  59. package/src/participant/participantMediaFakes.test-helper.ts +82 -0
  60. package/src/participant/participantNoMicrophoneDom.test.tsx +44 -0
  61. package/src/participant/participantPublicTypes.test.ts +11 -0
  62. package/src/participant/participantRecorderDomKit.test-helper.tsx +131 -0
  63. package/src/participant/participantRecorderFakes.test-helper.ts +47 -0
  64. package/src/participant/participantSpecRecorderFake.test-helper.ts +52 -0
  65. package/src/participant/participantThemeContrast.test.ts +9 -0
  66. package/src/participant/test-fixtures/composer-audio-denied.golden.html +1 -0
  67. package/src/participant/test-fixtures/composer-audio-idle.golden.html +1 -0
  68. package/src/participant/test-fixtures/composer-audio-recording.golden.html +1 -0
  69. package/src/participant/test-fixtures/composer-audio-review.golden.html +1 -0
  70. package/src/participant/test-fixtures/thread-audio-native.golden.html +1 -0
  71. package/src/participant/test-fixtures/thread-audio-player.golden.html +1 -0
  72. package/src/participant/test-fixtures/thread-audio-transcription-empty.golden.html +1 -0
  73. package/src/participant/test-fixtures/thread-audio-transcription-pending.golden.html +1 -0
  74. package/src/participant/test-fixtures/thread-audio-transcription-ready.golden.html +1 -0
  75. package/src/participant/test-fixtures/thread-audio-transcription-too_long.golden.html +1 -0
  76. package/src/participant/test-fixtures/thread-composer-microphone-idle.golden.html +1 -0
  77. package/src/participant/test-fixtures/thread-empty-draft.golden.html +1 -0
  78. package/src/participant/useParticipantAudioMediaEvents.ts +46 -0
  79. package/src/participant/useParticipantAudioPlayer.ts +52 -0
  80. package/src/participant/useParticipantAudioPlayerActions.ts +47 -0
  81. package/src/participant/useParticipantAudioRecorder.ts +153 -0
  82. package/src/participant/useParticipantAudioRecorderCore.ts +55 -0
  83. package/src/participant/useParticipantAudioRecorderEffects.ts +70 -0
  84. package/src/participant/useParticipantAudioSourceRetry.ts +42 -0
  85. package/src/participant/useParticipantSendAudio.test.tsx +85 -0
  86. package/src/participant/useParticipantSendAudio.ts +28 -0
  87. package/src/participant/useParticipantThreadController.ts +20 -7
  88. package/src/participant/useStableAttachmentUrl.ts +71 -0
  89. package/src/participant/useTranscriptionOverflow.ts +25 -0
  90. package/src/participant/useTranscriptionReadyAnnouncement.ts +24 -0
  91. 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
+ }