@adatechnology/conversations-ui 0.8.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 (129) 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-HMCOTZAX.js → chunk-NSOAUYNE.js} +33 -28
  4. package/dist/{chunk-O6457XHK.js → chunk-WCZNIJ3M.js} +16 -1
  5. package/dist/{chunk-L3OFAQPS.js → chunk-Y326LLEP.js} +4 -14
  6. package/dist/chunk-ZSOD5AQT.js +19 -0
  7. package/dist/flows/index.js +4 -2
  8. package/dist/index.d.ts +10 -3
  9. package/dist/index.js +8 -6
  10. package/dist/participant/index.d.ts +54 -3
  11. package/dist/participant/index.js +1722 -418
  12. package/dist/preview/index.d.ts +2 -2
  13. package/dist/preview/index.js +2 -2
  14. package/dist/styles.css +281 -23
  15. package/package.json +3 -2
  16. package/src/AudioRecorderButton.tsx +4 -28
  17. package/src/Tooltip.tsx +5 -12
  18. package/src/TooltipDom.test.tsx +78 -0
  19. package/src/audioRecorderFormat.ts +27 -0
  20. package/src/domHarness.test-helper.ts +53 -0
  21. package/src/participant/ConversationThread.tsx +4 -0
  22. package/src/participant/ParticipantAttachmentItem.tsx +7 -10
  23. package/src/participant/ParticipantAudioAttachment.tsx +34 -0
  24. package/src/participant/ParticipantAudioPlayer.tsx +68 -0
  25. package/src/participant/ParticipantAudioPlayerControls.tsx +68 -0
  26. package/src/participant/ParticipantAudioRecorderView.tsx +55 -0
  27. package/src/participant/ParticipantAudioTranscription.tsx +90 -0
  28. package/src/participant/ParticipantBackButton.tsx +1 -1
  29. package/src/participant/ParticipantBubbleBody.tsx +54 -0
  30. package/src/participant/ParticipantBubbleStatus.tsx +22 -28
  31. package/src/participant/ParticipantChannelBadges.tsx +10 -2
  32. package/src/participant/ParticipantComposer.tsx +51 -16
  33. package/src/participant/ParticipantComposerAudio.tsx +95 -0
  34. package/src/participant/ParticipantComposerControls.tsx +2 -0
  35. package/src/participant/ParticipantConversations.tsx +3 -0
  36. package/src/participant/ParticipantCopyableToken.tsx +1 -0
  37. package/src/participant/ParticipantFailedMenu.tsx +40 -0
  38. package/src/participant/ParticipantFailedMenuList.tsx +41 -0
  39. package/src/participant/ParticipantMessageBubble.test.tsx +6 -6
  40. package/src/participant/ParticipantMessageBubble.tsx +20 -52
  41. package/src/participant/ParticipantMessageText.test.tsx +1 -1
  42. package/src/participant/ParticipantMessageText.tsx +4 -2
  43. package/src/participant/ParticipantProtocolBadge.tsx +1 -0
  44. package/src/participant/ParticipantRecorderParts.tsx +118 -0
  45. package/src/participant/ParticipantScrollToLatest.tsx +1 -1
  46. package/src/participant/ParticipantThread.tsx +30 -12
  47. package/src/participant/ParticipantThreadHeader.test.tsx +1 -1
  48. package/src/participant/ParticipantThreadScreen.tsx +1 -0
  49. package/src/participant/failedMenuKeyboard.ts +29 -0
  50. package/src/participant/index.ts +3 -0
  51. package/src/participant/participantAudio.types.ts +10 -0
  52. package/src/participant/participantAudioCapture.test.ts +15 -0
  53. package/src/participant/participantAudioCapture.ts +85 -0
  54. package/src/participant/participantAudioCoordinator.ts +13 -0
  55. package/src/participant/participantAudioFormat.test.ts +93 -0
  56. package/src/participant/participantAudioFormat.ts +72 -0
  57. package/src/participant/participantAudioGolden.test.tsx +168 -0
  58. package/src/participant/participantAudioLabels.test.ts +22 -0
  59. package/src/participant/participantAudioLabels.ts +76 -0
  60. package/src/participant/participantAudioPlayerControlsDom.test.tsx +123 -0
  61. package/src/participant/participantAudioPlayerCss.test.ts +63 -0
  62. package/src/participant/participantAudioPlayerDom.test.tsx +154 -0
  63. package/src/participant/participantAudioPlayerDomKit.test-helper.tsx +84 -0
  64. package/src/participant/participantAudioPlayerState.ts +35 -0
  65. package/src/participant/participantAudioRecorderDom.test.tsx +127 -0
  66. package/src/participant/participantAudioRecorderInterruptionDom.test.tsx +135 -0
  67. package/src/participant/participantAudioRecorderSpecDom.test.tsx +110 -0
  68. package/src/participant/participantAudioRecorderState.test.ts +144 -0
  69. package/src/participant/participantAudioRecorderState.ts +93 -0
  70. package/src/participant/participantAudioRecorderView.test.tsx +98 -0
  71. package/src/participant/participantAudioSupport.ts +36 -0
  72. package/src/participant/participantAudioTranscriptionDom.test.tsx +198 -0
  73. package/src/participant/participantComposerAudioDom.test.tsx +185 -0
  74. package/src/participant/participantComposerAudioGolden.test.tsx +68 -0
  75. package/src/participant/participantComposerAudioSsr.test.tsx +74 -0
  76. package/src/participant/participantFailedMenuDom.test.tsx +154 -0
  77. package/src/participant/participantFailedMenuState.test.ts +61 -0
  78. package/src/participant/participantFailedMenuState.ts +57 -0
  79. package/src/participant/participantFailedMessage.test.tsx +83 -0
  80. package/src/participant/participantFileValidation.ts +1 -1
  81. package/src/participant/participantGolden.test.tsx +2 -0
  82. package/src/participant/participantGoldenScenarios.test-helper.tsx +14 -1
  83. package/src/participant/participantLabels.ts +10 -1
  84. package/src/participant/participantMediaFakes.test-helper.ts +82 -0
  85. package/src/participant/participantNoMicrophoneDom.test.tsx +44 -0
  86. package/src/participant/participantPublicTypes.test.ts +11 -0
  87. package/src/participant/participantRecorderDomKit.test-helper.tsx +131 -0
  88. package/src/participant/participantRecorderFakes.test-helper.ts +47 -0
  89. package/src/participant/participantSpecRecorderFake.test-helper.ts +52 -0
  90. package/src/participant/participantThemeContrast.test.ts +17 -0
  91. package/src/participant/participantTooltips.test.tsx +74 -0
  92. package/src/participant/test-fixtures/composer-audio-denied.golden.html +1 -0
  93. package/src/participant/test-fixtures/composer-audio-idle.golden.html +1 -0
  94. package/src/participant/test-fixtures/composer-audio-recording.golden.html +1 -0
  95. package/src/participant/test-fixtures/composer-audio-review.golden.html +1 -0
  96. package/src/participant/test-fixtures/thread-audio-native.golden.html +1 -0
  97. package/src/participant/test-fixtures/thread-audio-player.golden.html +1 -0
  98. package/src/participant/test-fixtures/thread-audio-transcription-empty.golden.html +1 -0
  99. package/src/participant/test-fixtures/thread-audio-transcription-pending.golden.html +1 -0
  100. package/src/participant/test-fixtures/thread-audio-transcription-ready.golden.html +1 -0
  101. package/src/participant/test-fixtures/thread-audio-transcription-too_long.golden.html +1 -0
  102. package/src/participant/test-fixtures/thread-avatars-initials.golden.html +1 -1
  103. package/src/participant/test-fixtures/thread-closed.golden.html +1 -1
  104. package/src/participant/test-fixtures/thread-composer-microphone-idle.golden.html +1 -0
  105. package/src/participant/test-fixtures/thread-default.golden.html +1 -1
  106. package/src/participant/test-fixtures/thread-empty-draft.golden.html +1 -0
  107. package/src/participant/test-fixtures/thread-error.golden.html +1 -1
  108. package/src/participant/test-fixtures/thread-loading.golden.html +1 -1
  109. package/src/participant/test-fixtures/thread-plain.golden.html +1 -1
  110. package/src/participant/test-fixtures/thread-tail-off.golden.html +1 -1
  111. package/src/participant/useFailedMenuEffects.ts +59 -0
  112. package/src/participant/useParticipantAudioMediaEvents.ts +46 -0
  113. package/src/participant/useParticipantAudioPlayer.ts +52 -0
  114. package/src/participant/useParticipantAudioPlayerActions.ts +47 -0
  115. package/src/participant/useParticipantAudioRecorder.ts +153 -0
  116. package/src/participant/useParticipantAudioRecorderCore.ts +55 -0
  117. package/src/participant/useParticipantAudioRecorderEffects.ts +70 -0
  118. package/src/participant/useParticipantAudioSourceRetry.ts +42 -0
  119. package/src/participant/useParticipantFailedMenu.ts +60 -0
  120. package/src/participant/useParticipantSendAudio.test.tsx +85 -0
  121. package/src/participant/useParticipantSendAudio.ts +28 -0
  122. package/src/participant/useParticipantThreadController.ts +20 -7
  123. package/src/participant/useStableAttachmentUrl.ts +71 -0
  124. package/src/participant/useSuppressTouchTooltips.ts +38 -0
  125. package/src/participant/useTranscriptionOverflow.ts +25 -0
  126. package/src/participant/useTranscriptionReadyAnnouncement.ts +24 -0
  127. package/src/styles.css +48 -5
  128. package/src/tooltipLayerRegistry.test.ts +56 -0
  129. package/src/tooltipLayerRegistry.ts +34 -0
@@ -3,6 +3,7 @@ import { useEffect, useState } from 'react'
3
3
  import type { ParticipantAttachment } from '@adatechnology/conversation-contracts'
4
4
 
5
5
  import { formatFileSize } from '../lib/format'
6
+ import { ParticipantAudioAttachment } from './ParticipantAudioAttachment'
6
7
  import { isSafeAttachmentUrl } from './participantAttachmentUrl'
7
8
  import type { ParticipantConversationsLabels } from './participantLabels'
8
9
 
@@ -42,7 +43,12 @@ function useAttachmentUrl(
42
43
  return url
43
44
  }
44
45
 
45
- export function ParticipantAttachmentItem({ attachment, resolveAttachmentUrl, labels }: ParticipantAttachmentItemProps) {
46
+ export function ParticipantAttachmentItem(props: ParticipantAttachmentItemProps) {
47
+ if (props.attachment.kind === 'audio') return <ParticipantAudioAttachment {...props} />
48
+ return <ParticipantFileAttachment {...props} />
49
+ }
50
+
51
+ function ParticipantFileAttachment({ attachment, resolveAttachmentUrl, labels }: ParticipantAttachmentItemProps) {
46
52
  const url = useAttachmentUrl(attachment, resolveAttachmentUrl)
47
53
 
48
54
  if (attachment.kind === 'image') {
@@ -54,15 +60,6 @@ export function ParticipantAttachmentItem({ attachment, resolveAttachmentUrl, la
54
60
  )
55
61
  }
56
62
 
57
- if (attachment.kind === 'audio') {
58
- return (
59
- <div className="cv-p-attachment cv-p-attachment--audio">
60
- <span className="cv-p-attachment__name">{attachment.filename}</span>
61
- <audio className="cv-p-attachment__audio" controls preload="none" src={url} />
62
- </div>
63
- )
64
- }
65
-
66
63
  return (
67
64
  <a
68
65
  className="cv-p-attachment cv-p-attachment--document"
@@ -0,0 +1,34 @@
1
+ import type { ParticipantAttachment } from '@adatechnology/conversation-contracts'
2
+
3
+ import type { ResolveParticipantAttachmentUrl } from './ParticipantAttachmentItem'
4
+ import { ParticipantAudioPlayer } from './ParticipantAudioPlayer'
5
+ import { ParticipantAudioTranscription } from './ParticipantAudioTranscription'
6
+ import { resolveParticipantAudioLabels } from './participantAudioLabels'
7
+ import type { ParticipantConversationsLabels } from './participantLabels'
8
+ import { useStableAttachmentUrl } from './useStableAttachmentUrl'
9
+
10
+ export type ParticipantAudioAttachmentProps = {
11
+ readonly attachment: ParticipantAttachment
12
+ readonly resolveAttachmentUrl: ResolveParticipantAttachmentUrl
13
+ readonly labels: ParticipantConversationsLabels
14
+ }
15
+
16
+ export function ParticipantAudioAttachment({ attachment, resolveAttachmentUrl, labels }: ParticipantAudioAttachmentProps) {
17
+ const source = useStableAttachmentUrl({ attachment, resolveAttachmentUrl })
18
+ const audioLabels = resolveParticipantAudioLabels(labels)
19
+ return (
20
+ <div className="cv-p-attachment cv-p-attachment--audio">
21
+ <ParticipantAudioPlayer
22
+ labels={audioLabels}
23
+ url={source.url}
24
+ durationMs={attachment.durationMs}
25
+ filename={attachment.filename}
26
+ isPending={source.status === 'pending'}
27
+ isUnavailable={source.status === 'failed'}
28
+ onSourceError={source.resolveAgain}
29
+ />
30
+ <span className="cv-p-attachment__name">{attachment.filename}</span>
31
+ <ParticipantAudioTranscription attachmentId={attachment.id} transcription={attachment.transcription} labels={audioLabels} />
32
+ </div>
33
+ )
34
+ }
@@ -0,0 +1,68 @@
1
+ import type { Ref } from 'react'
2
+
3
+ import type { ParticipantAudioLabels } from './participantAudioLabels'
4
+ import {
5
+ formatAudioClock,
6
+ formatPlaybackRate,
7
+ formatPositionValueText,
8
+ resolveSeekMax,
9
+ } from './participantAudioFormat'
10
+ import { AudioRateButton, AudioSeekRange, AudioToggleButton } from './ParticipantAudioPlayerControls'
11
+ import { useParticipantAudioPlayer } from './useParticipantAudioPlayer'
12
+
13
+ export type ParticipantAudioPlayerProps = {
14
+ readonly labels: ParticipantAudioLabels
15
+ readonly url: string | undefined
16
+ readonly durationMs?: number | undefined
17
+ readonly filename?: string
18
+ /** True while the host is still resolving the URL: the toggle answers aria-busy and the user taps again. */
19
+ readonly isPending?: boolean
20
+ readonly isUnavailable?: boolean
21
+ readonly onSourceError?: () => Promise<string | undefined>
22
+ readonly mediaRef?: Ref<HTMLAudioElement>
23
+ }
24
+
25
+ function fillRate(template: string, rate: number): string {
26
+ return template.replace('{rate}', formatPlaybackRate(rate))
27
+ }
28
+
29
+ export function ParticipantAudioPlayer({
30
+ labels,
31
+ url,
32
+ durationMs,
33
+ filename,
34
+ isPending = false,
35
+ isUnavailable = false,
36
+ onSourceError,
37
+ mediaRef,
38
+ }: ParticipantAudioPlayerProps) {
39
+ const player = useParticipantAudioPlayer({ url, durationMs, onSourceError, mediaRef })
40
+ const { state, duration, rate } = player
41
+ const position = Math.floor(state.position)
42
+ const hasFailed = isUnavailable || state.hasFailed
43
+
44
+ return (
45
+ <div className="cv-p-audio" role="group" aria-label={filename ? `${labels.audioPlayer}: ${filename}` : labels.audioPlayer}>
46
+ <AudioToggleButton
47
+ label={state.isPlaying ? labels.pauseAudio : labels.playAudio}
48
+ isPlaying={state.isPlaying}
49
+ isPending={isPending}
50
+ isDisabled={hasFailed}
51
+ onToggle={player.handleToggle}
52
+ />
53
+ <AudioSeekRange
54
+ label={labels.audioPosition}
55
+ max={resolveSeekMax({ duration, maxSeen: state.maxSeen, position })}
56
+ position={position}
57
+ valueText={formatPositionValueText({ template: labels.audioPositionValue, position, duration })}
58
+ isDisabled={hasFailed}
59
+ onSeek={player.seekTo}
60
+ onKeyDown={player.handleKeyDown}
61
+ />
62
+ <span className="cv-p-audio__time">{`${formatAudioClock(position)} / ${formatAudioClock(duration)}`}</span>
63
+ <AudioRateButton label={fillRate(labels.playbackRate, rate)} text={formatPlaybackRate(rate)} onClick={player.handleRateClick} />
64
+ <audio className="cv-p-audio__media" preload="none" src={url} {...player.mediaProps} />
65
+ {hasFailed ? <span className="cv-p-audio__error">{labels.audioUnavailable}</span> : null}
66
+ </div>
67
+ )
68
+ }
@@ -0,0 +1,68 @@
1
+ import { Pause, Play } from 'lucide-react'
2
+ import type { KeyboardEventHandler } from 'react'
3
+
4
+ type AudioToggleButtonProps = {
5
+ readonly label: string
6
+ readonly isPlaying: boolean
7
+ readonly isPending: boolean
8
+ readonly isDisabled: boolean
9
+ readonly onToggle: () => void
10
+ }
11
+
12
+ export function AudioToggleButton({ label, isPlaying, isPending, isDisabled, onToggle }: AudioToggleButtonProps) {
13
+ return (
14
+ <button
15
+ type="button"
16
+ className="cv-p-audio__toggle"
17
+ aria-label={label}
18
+ data-cv-tooltip={label}
19
+ aria-busy={isPending ? true : undefined}
20
+ disabled={isDisabled}
21
+ onClick={onToggle}
22
+ >
23
+ {isPlaying ? <Pause size={20} aria-hidden="true" focusable="false" /> : <Play size={20} aria-hidden="true" focusable="false" />}
24
+ </button>
25
+ )
26
+ }
27
+
28
+ type AudioSeekRangeProps = {
29
+ readonly label: string
30
+ readonly max: number
31
+ readonly position: number
32
+ readonly valueText: string
33
+ readonly isDisabled: boolean
34
+ readonly onSeek: (seconds: number) => void
35
+ readonly onKeyDown: KeyboardEventHandler<HTMLInputElement>
36
+ }
37
+
38
+ export function AudioSeekRange({ label, max, position, valueText, isDisabled, onSeek, onKeyDown }: AudioSeekRangeProps) {
39
+ return (
40
+ <input
41
+ type="range"
42
+ className="cv-p-audio__seek"
43
+ aria-label={label}
44
+ min={0}
45
+ max={max}
46
+ step={1}
47
+ value={Math.min(position, max)}
48
+ aria-valuetext={valueText}
49
+ disabled={isDisabled}
50
+ onChange={(event) => onSeek(Number(event.currentTarget.value))}
51
+ onKeyDown={onKeyDown}
52
+ />
53
+ )
54
+ }
55
+
56
+ type AudioRateButtonProps = {
57
+ readonly label: string
58
+ readonly text: string
59
+ readonly onClick: () => void
60
+ }
61
+
62
+ export function AudioRateButton({ label, text, onClick }: AudioRateButtonProps) {
63
+ return (
64
+ <button type="button" className="cv-p-audio__rate" aria-label={label} onClick={onClick}>
65
+ {text}
66
+ </button>
67
+ )
68
+ }
@@ -0,0 +1,55 @@
1
+ import type { Ref } from 'react'
2
+
3
+ import { DEFAULT_MAX_RECORDING_MILLISECONDS } from '../audioRecorderFormat'
4
+ import { formatRecordingElapsedLabel, resolveParticipantAudioLabels, type ParticipantAudioLabels } from './participantAudioLabels'
5
+ import { formatRecordingClock, type RecorderStatus } from './participantAudioRecorderState'
6
+ import type { ParticipantConversationsLabels } from './participantLabels'
7
+ import { RecordAudioButton, RecorderError, RecordingGroup, ReviewGroup } from './ParticipantRecorderParts'
8
+
9
+ export type ParticipantAudioRecorderActions = {
10
+ readonly onStart?: () => void
11
+ readonly onStop?: () => void
12
+ readonly onSend?: () => void
13
+ readonly onDiscard?: () => void
14
+ }
15
+
16
+ export type ParticipantAudioRecorderViewProps = {
17
+ readonly state: RecorderStatus
18
+ readonly elapsedMs: number
19
+ readonly labels?: Partial<ParticipantAudioLabels> | ParticipantConversationsLabels
20
+ readonly maxDurationMs?: number
21
+ readonly review?: { readonly url?: string; readonly audioRef?: Ref<HTMLAudioElement>; readonly durationMs?: number | undefined }
22
+ readonly actions?: ParticipantAudioRecorderActions
23
+ readonly disabled?: boolean
24
+ }
25
+
26
+ const ERROR_LABEL_BY_STATE = {
27
+ denied: 'microphoneDenied',
28
+ unsupported: 'recordingUnsupported',
29
+ empty: 'audioEmpty',
30
+ tooLarge: 'audioTooLarge',
31
+ } as const satisfies Partial<Record<RecorderStatus, keyof ParticipantAudioLabels>>
32
+
33
+ export function resolveRecorderErrorMessage(state: RecorderStatus, labels: ParticipantAudioLabels): string | undefined {
34
+ return state in ERROR_LABEL_BY_STATE ? labels[ERROR_LABEL_BY_STATE[state as keyof typeof ERROR_LABEL_BY_STATE]] : undefined
35
+ }
36
+
37
+ export function ParticipantAudioRecorderView(props: ParticipantAudioRecorderViewProps) {
38
+ const { state, elapsedMs, maxDurationMs = DEFAULT_MAX_RECORDING_MILLISECONDS, review, actions, disabled = false } = props
39
+ const labels = resolveParticipantAudioLabels(props.labels)
40
+
41
+ if (state === 'recording') {
42
+ const clockText = formatRecordingElapsedLabel(labels.recordingElapsed, formatRecordingClock(elapsedMs), formatRecordingClock(maxDurationMs))
43
+ return <RecordingGroup labels={labels} clockText={clockText} onStop={actions?.onStop} onDiscard={actions?.onDiscard} />
44
+ }
45
+ if (state === 'review') {
46
+ return <ReviewGroup labels={labels} url={review?.url} audioRef={review?.audioRef} durationMs={review?.durationMs} onSend={actions?.onSend} onDiscard={actions?.onDiscard} disabled={disabled} />
47
+ }
48
+ const errorMessage = resolveRecorderErrorMessage(state, labels)
49
+ return (
50
+ <>
51
+ {errorMessage ? <RecorderError message={errorMessage} /> : null}
52
+ <RecordAudioButton label={labels.recordAudio} isBusy={state === 'requesting'} disabled={disabled} onClick={actions?.onStart} />
53
+ </>
54
+ )
55
+ }
@@ -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
+ }
@@ -7,7 +7,7 @@ export type ParticipantBackButtonProps = {
7
7
 
8
8
  export function ParticipantBackButton({ label, onBack }: ParticipantBackButtonProps) {
9
9
  return (
10
- <button type="button" className="cv-p-thread__back" aria-label={label} onClick={onBack}>
10
+ <button type="button" className="cv-p-thread__back" aria-label={label} data-cv-tooltip={label} onClick={onBack}>
11
11
  <ArrowLeft size={24} aria-hidden="true" focusable="false" />
12
12
  </button>
13
13
  )
@@ -0,0 +1,54 @@
1
+ import { formatTimestamp } from '../lib/format'
2
+ import { ParticipantAttachmentItem, type ResolveParticipantAttachmentUrl } from './ParticipantAttachmentItem'
3
+ import { OwnStatus } from './ParticipantBubbleStatus'
4
+ import { ParticipantFailedMenu } from './ParticipantFailedMenu'
5
+ import type { BubbleContent } from './ParticipantMessageBubble'
6
+ import { ParticipantMessageText } from './ParticipantMessageText'
7
+ import type { ParticipantConversationsLabels } from './participantLabels'
8
+ import type { ParticipantOwnMessageStatus } from './participantMessages'
9
+
10
+ type BubbleBodyProps = {
11
+ readonly content: BubbleContent
12
+ readonly labels: ParticipantConversationsLabels
13
+ readonly ownStatus?: ParticipantOwnMessageStatus
14
+ readonly textId?: string
15
+ readonly resolveAttachmentUrl: ResolveParticipantAttachmentUrl
16
+ readonly onRetry?: () => void
17
+ readonly onDiscard?: () => void
18
+ readonly onEdit?: () => void
19
+ }
20
+
21
+ function BubbleHeading({ content, labels }: Pick<BubbleBodyProps, 'content' | 'labels'>) {
22
+ if (content.isMine) return <span className="cv-p-sr-only">{labels.me}</span>
23
+ if (!content.authorName) return null
24
+ return <span className="cv-p-bubble__author">{content.authorName}</span>
25
+ }
26
+
27
+ export function BubbleBody({ content, labels, ownStatus, textId, resolveAttachmentUrl, onRetry, onDiscard, onEdit }: BubbleBodyProps) {
28
+ return (
29
+ <>
30
+ <BubbleHeading content={content} labels={labels} />
31
+ {content.text ? <ParticipantMessageText text={content.text} labels={labels} id={textId} /> : null}
32
+ {content.attachments.map((attachment) => (
33
+ <ParticipantAttachmentItem
34
+ key={attachment.id}
35
+ attachment={attachment}
36
+ resolveAttachmentUrl={resolveAttachmentUrl}
37
+ labels={labels}
38
+ />
39
+ ))}
40
+ {content.pendingFilenames.map((filename, index) => (
41
+ <span key={`${filename}-${index}`} className="cv-p-attachment cv-p-attachment--pending">
42
+ {filename}
43
+ </span>
44
+ ))}
45
+ <span className="cv-p-bubble__meta">
46
+ <time className="cv-p-bubble__time" dateTime={content.createdAt}>
47
+ {formatTimestamp(content.createdAt)}
48
+ </time>
49
+ {ownStatus ? <OwnStatus status={ownStatus} labels={labels} onRetry={onRetry} /> : null}
50
+ {ownStatus === 'failed' ? <ParticipantFailedMenu labels={labels} onEdit={onEdit} onDiscard={onDiscard} /> : null}
51
+ </span>
52
+ </>
53
+ )
54
+ }
@@ -24,44 +24,38 @@ export function OwnStatus({ status, labels, onRetry }: OwnStatusProps) {
24
24
  const ticksStatus = status === 'sending' ? 'queued' : status
25
25
  const isVisibleText = status === 'sending' || status === 'queued' || status === 'failed'
26
26
 
27
- if (status === 'failed' && onRetry) {
28
- return (
29
- <button type="button" className="cv-p-bubble__retry" onClick={onRetry}>
30
- <StatusTicks status="failed" appearance="stylesheet" />
31
- <span>{labels.statusFailed}</span>
32
- </button>
33
- )
34
- }
35
-
36
27
  const text = status === 'failed' ? labels.statusFailedShort : statusText(status, labels)
28
+ const isTextVisible = isVisibleText && !(status === 'failed' && onRetry)
37
29
  return (
38
30
  <span className="cv-p-bubble__status">
39
- <StatusTicks status={ticksStatus} appearance="stylesheet" />
40
- <span className={isVisibleText ? 'cv-p-bubble__status-text' : 'cv-p-sr-only'}>{text}</span>
31
+ <StatusTicks status={ticksStatus} appearance="stylesheet" title={isTextVisible ? undefined : text} />
32
+ <span className={isTextVisible ? 'cv-p-bubble__status-text' : 'cv-p-sr-only'}>{text}</span>
41
33
  </span>
42
34
  )
43
35
  }
44
36
 
45
- type FailedActionsProps = {
37
+ type RetryButtonProps = {
46
38
  readonly labels: ParticipantConversationsLabels
47
- readonly onDiscard?: () => void
48
- readonly onEdit?: () => void
39
+ readonly onRetry: () => void
40
+ /** Id of the message text, read after the name so the button says which message it resends. */
41
+ readonly describedBy?: string
49
42
  }
50
43
 
51
- export function FailedActions({ labels, onDiscard, onEdit }: FailedActionsProps) {
52
- if (!onDiscard && !onEdit) return null
44
+ export function RetryButton({ labels, onRetry, describedBy }: RetryButtonProps) {
53
45
  return (
54
- <span className="cv-p-bubble__actions">
55
- {onEdit ? (
56
- <button type="button" className="cv-p-bubble__action" onClick={onEdit}>
57
- {labels.edit}
58
- </button>
59
- ) : null}
60
- {onDiscard ? (
61
- <button type="button" className="cv-p-bubble__action" onClick={onDiscard}>
62
- {labels.discard}
63
- </button>
64
- ) : null}
65
- </span>
46
+ <button
47
+ type="button"
48
+ className="cv-p-failed-retry"
49
+ aria-label={labels.statusFailed}
50
+ aria-describedby={describedBy}
51
+ data-cv-tooltip={labels.statusFailed}
52
+ onClick={onRetry}
53
+ >
54
+ <span className="cv-p-failed-retry__icon">
55
+ <svg viewBox="0 0 16 16" width="14" height="14" aria-hidden="true" focusable="false">
56
+ <path d="M13 8a5 5 0 1 1-1.6-3.7M13 2.5v3h-3" fill="none" stroke="currentColor" strokeWidth="1.5" />
57
+ </svg>
58
+ </span>
59
+ </button>
66
60
  )
67
61
  }
@@ -59,7 +59,11 @@ export function ParticipantChannelBadges({
59
59
  <span className="cv-p-channels">
60
60
  <span className="cv-p-sr-only">{`${labels.channelsGroup}: `}</span>
61
61
  {descriptors.map((descriptor, index) => (
62
- <span key={descriptor.channel} className={`cv-p-channel cv-p-channel--${descriptor.channel}`}>
62
+ <span
63
+ key={descriptor.channel}
64
+ className={`cv-p-channel cv-p-channel--${descriptor.channel}`}
65
+ data-cv-tooltip={resolveParticipantChannelLabel(descriptor, labels)}
66
+ >
63
67
  <BadgeContent
64
68
  descriptor={descriptor}
65
69
  labels={labels}
@@ -75,7 +79,11 @@ export function ParticipantChannelBadges({
75
79
  return (
76
80
  <ul className="cv-p-channels" aria-label={labels.channelsGroup}>
77
81
  {descriptors.map((descriptor) => (
78
- <li key={descriptor.channel} className={`cv-p-channel cv-p-channel--${descriptor.channel}`}>
82
+ <li
83
+ key={descriptor.channel}
84
+ className={`cv-p-channel cv-p-channel--${descriptor.channel}`}
85
+ data-cv-tooltip={resolveParticipantChannelLabel(descriptor, labels)}
86
+ >
79
87
  <BadgeContent descriptor={descriptor} labels={labels} separator="" iconSize={iconSize} />
80
88
  </li>
81
89
  ))}
@@ -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 = {
@@ -60,7 +67,7 @@ function FileList({ files, labels, onRemove }: FileListProps) {
60
67
  <li key={`${file.name}-${file.size}-${index}`} className="cv-p-files__item">
61
68
  <span className="cv-p-files__name">{file.name}</span>
62
69
  <span className="cv-p-files__size">{formatFileSize(file.size)}</span>
63
- <button type="button" className="cv-p-files__remove" aria-label={`${labels.removeAttachment}: ${file.name}`} onClick={() => onRemove(file)}>
70
+ <button type="button" className="cv-p-files__remove" aria-label={`${labels.removeAttachment}: ${file.name}`} data-cv-tooltip={labels.removeAttachment} onClick={() => onRemove(file)}>
64
71
  <span aria-hidden="true">×</span>
65
72
  <span className="cv-p-sr-only">{labels.removeAttachment}</span>
66
73
  </button>
@@ -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
  }