@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.
- package/README.md +106 -0
- package/dist/{ConversationSimulatorPanel-q-wVgBCN.d.ts → ConversationSimulatorPanel-P6eqTSAO.d.ts} +1 -16
- package/dist/{chunk-HMCOTZAX.js → chunk-NSOAUYNE.js} +33 -28
- package/dist/{chunk-O6457XHK.js → chunk-WCZNIJ3M.js} +16 -1
- package/dist/{chunk-L3OFAQPS.js → chunk-Y326LLEP.js} +4 -14
- package/dist/chunk-ZSOD5AQT.js +19 -0
- package/dist/flows/index.js +4 -2
- package/dist/index.d.ts +10 -3
- package/dist/index.js +8 -6
- package/dist/participant/index.d.ts +54 -3
- package/dist/participant/index.js +1722 -418
- package/dist/preview/index.d.ts +2 -2
- package/dist/preview/index.js +2 -2
- package/dist/styles.css +281 -23
- package/package.json +3 -2
- package/src/AudioRecorderButton.tsx +4 -28
- package/src/Tooltip.tsx +5 -12
- package/src/TooltipDom.test.tsx +78 -0
- package/src/audioRecorderFormat.ts +27 -0
- package/src/domHarness.test-helper.ts +53 -0
- package/src/participant/ConversationThread.tsx +4 -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/ParticipantBackButton.tsx +1 -1
- package/src/participant/ParticipantBubbleBody.tsx +54 -0
- package/src/participant/ParticipantBubbleStatus.tsx +22 -28
- package/src/participant/ParticipantChannelBadges.tsx +10 -2
- package/src/participant/ParticipantComposer.tsx +51 -16
- package/src/participant/ParticipantComposerAudio.tsx +95 -0
- package/src/participant/ParticipantComposerControls.tsx +2 -0
- package/src/participant/ParticipantConversations.tsx +3 -0
- package/src/participant/ParticipantCopyableToken.tsx +1 -0
- package/src/participant/ParticipantFailedMenu.tsx +40 -0
- package/src/participant/ParticipantFailedMenuList.tsx +41 -0
- package/src/participant/ParticipantMessageBubble.test.tsx +6 -6
- package/src/participant/ParticipantMessageBubble.tsx +20 -52
- package/src/participant/ParticipantMessageText.test.tsx +1 -1
- package/src/participant/ParticipantMessageText.tsx +4 -2
- package/src/participant/ParticipantProtocolBadge.tsx +1 -0
- package/src/participant/ParticipantRecorderParts.tsx +118 -0
- package/src/participant/ParticipantScrollToLatest.tsx +1 -1
- package/src/participant/ParticipantThread.tsx +30 -12
- package/src/participant/ParticipantThreadHeader.test.tsx +1 -1
- package/src/participant/ParticipantThreadScreen.tsx +1 -0
- package/src/participant/failedMenuKeyboard.ts +29 -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/participantFailedMenuDom.test.tsx +154 -0
- package/src/participant/participantFailedMenuState.test.ts +61 -0
- package/src/participant/participantFailedMenuState.ts +57 -0
- package/src/participant/participantFailedMessage.test.tsx +83 -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 +10 -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 +17 -0
- package/src/participant/participantTooltips.test.tsx +74 -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-avatars-initials.golden.html +1 -1
- package/src/participant/test-fixtures/thread-closed.golden.html +1 -1
- package/src/participant/test-fixtures/thread-composer-microphone-idle.golden.html +1 -0
- package/src/participant/test-fixtures/thread-default.golden.html +1 -1
- package/src/participant/test-fixtures/thread-empty-draft.golden.html +1 -0
- package/src/participant/test-fixtures/thread-error.golden.html +1 -1
- package/src/participant/test-fixtures/thread-loading.golden.html +1 -1
- package/src/participant/test-fixtures/thread-plain.golden.html +1 -1
- package/src/participant/test-fixtures/thread-tail-off.golden.html +1 -1
- package/src/participant/useFailedMenuEffects.ts +59 -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/useParticipantFailedMenu.ts +60 -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/useSuppressTouchTooltips.ts +38 -0
- package/src/participant/useTranscriptionOverflow.ts +25 -0
- package/src/participant/useTranscriptionReadyAnnouncement.ts +24 -0
- package/src/styles.css +48 -5
- package/src/tooltipLayerRegistry.test.ts +56 -0
- 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(
|
|
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={
|
|
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
|
|
37
|
+
type RetryButtonProps = {
|
|
46
38
|
readonly labels: ParticipantConversationsLabels
|
|
47
|
-
readonly
|
|
48
|
-
|
|
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
|
|
52
|
-
if (!onDiscard && !onEdit) return null
|
|
44
|
+
export function RetryButton({ labels, onRetry, describedBy }: RetryButtonProps) {
|
|
53
45
|
return (
|
|
54
|
-
<
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
{
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
|
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
|
|
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,
|
|
147
|
+
const { value, onChange, files, onSend, labels, disabled = false, isSending = false } = props
|
|
100
148
|
const { rejection, handleFilesChosen, handleRemoveFile } = useFileSelection(props)
|
|
101
149
|
const canSend = !disabled && !isSending && (value.trim().length > 0 || files.length > 0)
|
|
102
150
|
|
|
@@ -116,20 +164,7 @@ export function ParticipantComposer(props: ParticipantComposerProps) {
|
|
|
116
164
|
{rejectionText(rejection, labels)}
|
|
117
165
|
</p>
|
|
118
166
|
) : null}
|
|
119
|
-
<
|
|
120
|
-
{channelCapabilityFor(channel).attachments.accepted ? (
|
|
121
|
-
<AttachControl labels={labels} disabled={disabled} acceptedTypes={props.acceptedTypes} onChosen={handleFilesChosen} />
|
|
122
|
-
) : null}
|
|
123
|
-
<MessageField
|
|
124
|
-
value={value}
|
|
125
|
-
label={labels.messageInputLabel}
|
|
126
|
-
placeholder={labels.messageInputPlaceholder}
|
|
127
|
-
disabled={disabled}
|
|
128
|
-
maxLength={props.maxLength}
|
|
129
|
-
onChange={onChange}
|
|
130
|
-
/>
|
|
131
|
-
<SendButton label={labels.send} disabled={!canSend} />
|
|
132
|
-
</div>
|
|
167
|
+
<ComposerRow composer={props} canSend={canSend} onFilesChosen={handleFilesChosen} />
|
|
133
168
|
</form>
|
|
134
169
|
)
|
|
135
170
|
}
|