@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
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { useEffect, useRef, type ReactNode, type RefObject } from 'react'
|
|
2
|
+
|
|
3
|
+
import { maxBytesForChannel } from './participantFileValidation'
|
|
4
|
+
import { resolveParticipantAudioLabels } from './participantAudioLabels'
|
|
5
|
+
import type { ComposerAudioSettings } from './participantAudioSupport'
|
|
6
|
+
import type { RecorderStatus } from './participantAudioRecorderState'
|
|
7
|
+
import type { ParticipantConversationsLabels } from './participantLabels'
|
|
8
|
+
import { ParticipantAudioRecorderView, resolveRecorderErrorMessage } from './ParticipantAudioRecorderView'
|
|
9
|
+
import { RecordAudioButton, RecorderError } from './ParticipantRecorderParts'
|
|
10
|
+
import { useParticipantAudioRecorder, type ParticipantAudioRecorder } from './useParticipantAudioRecorder'
|
|
11
|
+
|
|
12
|
+
export type ComposerAudioRowProps = {
|
|
13
|
+
readonly settings: ComposerAudioSettings
|
|
14
|
+
readonly labels: ParticipantConversationsLabels
|
|
15
|
+
readonly disabled: boolean
|
|
16
|
+
readonly isDraftEmpty: boolean
|
|
17
|
+
/** The send button of the text path; the microphone only takes its place while the draft is empty. */
|
|
18
|
+
readonly sendButton: ReactNode
|
|
19
|
+
/** Attach control and message field, drawn only while no recording is in progress. */
|
|
20
|
+
readonly children: ReactNode
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function isCapturingStatus(status: RecorderStatus): boolean {
|
|
24
|
+
return status === 'recording' || status === 'review'
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function useRestoreMicrophoneFocus(status: RecorderStatus): RefObject<HTMLButtonElement | null> {
|
|
28
|
+
const microphoneRef = useRef<HTMLButtonElement>(null)
|
|
29
|
+
const wasCapturingRef = useRef(false)
|
|
30
|
+
useEffect(() => {
|
|
31
|
+
const isCapturing = isCapturingStatus(status)
|
|
32
|
+
if (wasCapturingRef.current && !isCapturing) microphoneRef.current?.focus()
|
|
33
|
+
wasCapturingRef.current = isCapturing
|
|
34
|
+
}, [status])
|
|
35
|
+
return microphoneRef
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
type RecorderLineProps = {
|
|
39
|
+
readonly recorder: ParticipantAudioRecorder
|
|
40
|
+
readonly labels: ParticipantConversationsLabels
|
|
41
|
+
readonly onSendAudio: (file: File) => void
|
|
42
|
+
readonly disabled: boolean
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function RecorderLine({ recorder, labels, onSendAudio, disabled }: RecorderLineProps) {
|
|
46
|
+
function handleSendAudio(): void {
|
|
47
|
+
const file = recorder.confirm()
|
|
48
|
+
if (file) onSendAudio(file)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return (
|
|
52
|
+
<ParticipantAudioRecorderView
|
|
53
|
+
state={recorder.status}
|
|
54
|
+
elapsedMs={recorder.elapsedMs}
|
|
55
|
+
labels={labels}
|
|
56
|
+
disabled={disabled}
|
|
57
|
+
maxDurationMs={recorder.limits.maxDurationMs}
|
|
58
|
+
review={{ url: recorder.recorded?.objectUrl, audioRef: recorder.audioRef, durationMs: recorder.recorded?.durationMs }}
|
|
59
|
+
actions={{ onStop: recorder.stop, onSend: handleSendAudio, onDiscard: recorder.discard }}
|
|
60
|
+
/>
|
|
61
|
+
)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function ComposerAudioRow({ settings, labels, disabled, isDraftEmpty, sendButton, children }: ComposerAudioRowProps) {
|
|
65
|
+
const recorder = useParticipantAudioRecorder({
|
|
66
|
+
options: settings.audioRecording,
|
|
67
|
+
channelMaxBytes: maxBytesForChannel(settings.channel),
|
|
68
|
+
})
|
|
69
|
+
const microphoneRef = useRestoreMicrophoneFocus(recorder.status)
|
|
70
|
+
const audioLabels = resolveParticipantAudioLabels(labels)
|
|
71
|
+
const errorMessage = resolveRecorderErrorMessage(recorder.status, audioLabels)
|
|
72
|
+
|
|
73
|
+
if (isCapturingStatus(recorder.status)) {
|
|
74
|
+
return <RecorderLine recorder={recorder} labels={labels} onSendAudio={settings.onSendAudio} disabled={disabled} />
|
|
75
|
+
}
|
|
76
|
+
return (
|
|
77
|
+
<>
|
|
78
|
+
{errorMessage ? <RecorderError message={errorMessage} /> : null}
|
|
79
|
+
<div className="cv-p-composer__row">
|
|
80
|
+
{children}
|
|
81
|
+
{isDraftEmpty ? (
|
|
82
|
+
<RecordAudioButton
|
|
83
|
+
label={audioLabels.recordAudio}
|
|
84
|
+
isBusy={recorder.status === 'requesting'}
|
|
85
|
+
disabled={disabled}
|
|
86
|
+
onClick={() => void recorder.start()}
|
|
87
|
+
buttonRef={microphoneRef}
|
|
88
|
+
/>
|
|
89
|
+
) : (
|
|
90
|
+
sendButton
|
|
91
|
+
)}
|
|
92
|
+
</div>
|
|
93
|
+
</>
|
|
94
|
+
)
|
|
95
|
+
}
|
|
@@ -29,6 +29,7 @@ export function AttachControl({ labels, disabled, acceptedTypes, onChosen }: Att
|
|
|
29
29
|
type="button"
|
|
30
30
|
className="cv-p-composer__attach"
|
|
31
31
|
aria-label={labels.attach}
|
|
32
|
+
data-cv-tooltip={labels.attach}
|
|
32
33
|
disabled={disabled}
|
|
33
34
|
onClick={() => fileInputRef.current?.click()}
|
|
34
35
|
>
|
|
@@ -44,6 +45,7 @@ export function SendButton({ label, disabled }: { readonly label: string; readon
|
|
|
44
45
|
type="submit"
|
|
45
46
|
className="cv-p-button cv-p-button--primary cv-p-composer__send"
|
|
46
47
|
aria-label={label}
|
|
48
|
+
data-cv-tooltip={label}
|
|
47
49
|
disabled={disabled}
|
|
48
50
|
>
|
|
49
51
|
<Send size={20} aria-hidden="true" focusable="false" />
|
|
@@ -13,6 +13,7 @@ import type {
|
|
|
13
13
|
ParticipantSubjectIconRenderer,
|
|
14
14
|
} from './participant.types'
|
|
15
15
|
import type { ParticipantConversationsApi, ParticipantPendingMessage } from './participantApi.types'
|
|
16
|
+
import type { ParticipantAudioRecordingOptions } from './participantAudio.types'
|
|
16
17
|
import type { ParticipantConversationsLabels } from './participantLabels'
|
|
17
18
|
import { useParticipantInbox } from './useParticipantInbox'
|
|
18
19
|
|
|
@@ -41,6 +42,8 @@ export type ParticipantConversationsProps = {
|
|
|
41
42
|
renderAuthorAvatar?: ParticipantAuthorAvatarRenderer
|
|
42
43
|
/** Speech-bubble tail on the bottom corner; false removes it. Default true. */
|
|
43
44
|
tail?: boolean
|
|
45
|
+
/** Absent draws no microphone. Present lets the user record and send an audio as a message of its own. */
|
|
46
|
+
audioRecording?: ParticipantAudioRecordingOptions
|
|
44
47
|
}
|
|
45
48
|
|
|
46
49
|
export function ParticipantConversations(props: ParticipantConversationsProps) {
|
|
@@ -43,6 +43,7 @@ export function ParticipantLinkCopyButton({ value, labels }: ParticipantLinkCopy
|
|
|
43
43
|
type="button"
|
|
44
44
|
className="cv-p-text__link-copy"
|
|
45
45
|
aria-label={`${labels.copyValue} ${value}`}
|
|
46
|
+
data-cv-tooltip={labels.copyValue}
|
|
46
47
|
onClick={() => copy(value, labels.valueCopied)}
|
|
47
48
|
>
|
|
48
49
|
{isCopied ? <Check size={14} aria-hidden={true} /> : <Copy size={14} aria-hidden={true} />}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { useId } from 'react'
|
|
2
|
+
|
|
3
|
+
import { ParticipantFailedMenuList } from './ParticipantFailedMenuList'
|
|
4
|
+
import { DEFAULT_MESSAGE_OPTIONS_LABEL, type ParticipantConversationsLabels } from './participantLabels'
|
|
5
|
+
import { useParticipantFailedMenu } from './useParticipantFailedMenu'
|
|
6
|
+
|
|
7
|
+
type ParticipantFailedMenuProps = {
|
|
8
|
+
readonly labels: ParticipantConversationsLabels
|
|
9
|
+
readonly onEdit?: () => void
|
|
10
|
+
readonly onDiscard?: () => void
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function ParticipantFailedMenu({ labels, onEdit, onDiscard }: ParticipantFailedMenuProps) {
|
|
14
|
+
const menu = useParticipantFailedMenu()
|
|
15
|
+
const triggerId = useId()
|
|
16
|
+
if (!onEdit && !onDiscard) return null
|
|
17
|
+
const label = labels.messageOptions ?? DEFAULT_MESSAGE_OPTIONS_LABEL
|
|
18
|
+
|
|
19
|
+
return (
|
|
20
|
+
<span className="cv-p-failed-menu" ref={menu.containerRef} onBlur={menu.handleBlur}>
|
|
21
|
+
<button
|
|
22
|
+
type="button"
|
|
23
|
+
id={triggerId}
|
|
24
|
+
ref={menu.triggerRef}
|
|
25
|
+
className="cv-p-failed-menu__trigger"
|
|
26
|
+
aria-haspopup="menu"
|
|
27
|
+
aria-expanded={menu.state.isOpen}
|
|
28
|
+
aria-label={label}
|
|
29
|
+
data-cv-tooltip={menu.state.isOpen ? undefined : label}
|
|
30
|
+
onClick={menu.handleTriggerClick}
|
|
31
|
+
onKeyDown={menu.handleTriggerKeyDown}
|
|
32
|
+
>
|
|
33
|
+
<span aria-hidden="true">⋯</span>
|
|
34
|
+
</button>
|
|
35
|
+
{menu.state.isOpen ? (
|
|
36
|
+
<ParticipantFailedMenuList menu={menu} labelledBy={triggerId} labels={labels} onEdit={onEdit} onDiscard={onDiscard} />
|
|
37
|
+
) : null}
|
|
38
|
+
</span>
|
|
39
|
+
)
|
|
40
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { ParticipantFailedMenuController } from './useParticipantFailedMenu'
|
|
2
|
+
import type { ParticipantConversationsLabels } from './participantLabels'
|
|
3
|
+
|
|
4
|
+
type ParticipantFailedMenuListProps = {
|
|
5
|
+
readonly menu: ParticipantFailedMenuController
|
|
6
|
+
readonly labelledBy: string
|
|
7
|
+
readonly labels: ParticipantConversationsLabels
|
|
8
|
+
readonly onEdit?: () => void
|
|
9
|
+
readonly onDiscard?: () => void
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function ParticipantFailedMenuList({ menu, labelledBy, labels, onEdit, onDiscard }: ParticipantFailedMenuListProps) {
|
|
13
|
+
function runAndClose(action: () => void): () => void {
|
|
14
|
+
return () => {
|
|
15
|
+
menu.close()
|
|
16
|
+
action()
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const className = `cv-p-failed-menu__list${menu.placement === 'below' ? ' cv-p-failed-menu__list--below' : ''}`
|
|
21
|
+
return (
|
|
22
|
+
<div role="menu" aria-labelledby={labelledBy} ref={menu.menuRef} className={className} onKeyDown={menu.handleMenuKeyDown}>
|
|
23
|
+
{onEdit ? (
|
|
24
|
+
<button type="button" role="menuitem" tabIndex={-1} className="cv-p-failed-menu__item" onClick={runAndClose(onEdit)}>
|
|
25
|
+
{labels.edit}
|
|
26
|
+
</button>
|
|
27
|
+
) : null}
|
|
28
|
+
{onDiscard ? (
|
|
29
|
+
<button
|
|
30
|
+
type="button"
|
|
31
|
+
role="menuitem"
|
|
32
|
+
tabIndex={-1}
|
|
33
|
+
className="cv-p-failed-menu__item cv-p-failed-menu__item--danger"
|
|
34
|
+
onClick={runAndClose(onDiscard)}
|
|
35
|
+
>
|
|
36
|
+
{labels.discard}
|
|
37
|
+
</button>
|
|
38
|
+
) : null}
|
|
39
|
+
</div>
|
|
40
|
+
)
|
|
41
|
+
}
|
|
@@ -112,22 +112,22 @@ describe('ParticipantMessageBubble', () => {
|
|
|
112
112
|
const withoutRetry = renderPending('failed')
|
|
113
113
|
|
|
114
114
|
expect(withRetry).toContain('<button')
|
|
115
|
-
expect(withRetry).toContain('Failed — tap to retry')
|
|
115
|
+
expect(withRetry).toContain('aria-label="Failed — tap to retry"')
|
|
116
|
+
expect(withRetry).toContain('cv-p-failed-retry')
|
|
116
117
|
expect(withRetry).toContain('cv-status-ticks--failed')
|
|
117
118
|
expect(findUtilityClassTokens(withRetry)).toEqual([])
|
|
118
119
|
expect(withoutRetry).not.toContain('<button')
|
|
119
120
|
expect(withoutRetry).toContain('cv-status-ticks--failed')
|
|
120
121
|
})
|
|
121
122
|
|
|
122
|
-
it('
|
|
123
|
+
it('offers edit and discard through the menu trigger only when a handler exists', () => {
|
|
123
124
|
const none = renderPending('failed')
|
|
124
125
|
const both = renderPending('failed', () => undefined, { onDiscard: () => undefined, onEdit: () => undefined })
|
|
125
126
|
const notFailed = renderPending('queued', undefined, { onDiscard: () => undefined, onEdit: () => undefined })
|
|
126
127
|
|
|
127
|
-
expect(none).not.toContain('cv-p-
|
|
128
|
-
expect(both).toContain('
|
|
129
|
-
expect(
|
|
130
|
-
expect(notFailed).not.toContain('cv-p-bubble__action')
|
|
128
|
+
expect(none).not.toContain('cv-p-failed-menu')
|
|
129
|
+
expect(both).toContain('aria-haspopup="menu"')
|
|
130
|
+
expect(notFailed).not.toContain('cv-p-failed-menu')
|
|
131
131
|
})
|
|
132
132
|
|
|
133
133
|
it('a failed server message shows only "Failed", with no button', () => {
|
|
@@ -1,11 +1,10 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { useId, type ReactNode } from 'react'
|
|
2
2
|
|
|
3
3
|
import type { MessageDeliveryStatus, ParticipantAttachment } from '@adatechnology/conversation-contracts'
|
|
4
4
|
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
import { ParticipantAttachmentItem, type ResolveParticipantAttachmentUrl } from './ParticipantAttachmentItem'
|
|
5
|
+
import { BubbleBody } from './ParticipantBubbleBody'
|
|
6
|
+
import { RetryButton } from './ParticipantBubbleStatus'
|
|
7
|
+
import type { ResolveParticipantAttachmentUrl } from './ParticipantAttachmentItem'
|
|
9
8
|
import type { ParticipantConversationsLabels } from './participantLabels'
|
|
10
9
|
import type { ParticipantPerspective } from './participantPerspective'
|
|
11
10
|
import {
|
|
@@ -20,11 +19,11 @@ export type ParticipantMessageBubbleProps = {
|
|
|
20
19
|
readonly labels: ParticipantConversationsLabels
|
|
21
20
|
readonly confirmsRead: boolean
|
|
22
21
|
readonly resolveAttachmentUrl: ResolveParticipantAttachmentUrl
|
|
23
|
-
/** Absent means no retry button, even when the message failed. */
|
|
22
|
+
/** Absent means no retry button beside the bubble, even when the message failed. */
|
|
24
23
|
readonly onRetry?: () => void
|
|
25
|
-
/** Absent means no discard
|
|
24
|
+
/** Absent means no discard item in the failed-message menu. */
|
|
26
25
|
readonly onDiscard?: () => void
|
|
27
|
-
/** Absent means no edit
|
|
26
|
+
/** Absent means no edit item in the failed-message menu. */
|
|
28
27
|
readonly onEdit?: () => void
|
|
29
28
|
/** Opt-in slot to the left of a received bubble; null reserves the space, absent changes nothing. */
|
|
30
29
|
readonly avatar?: ReactNode
|
|
@@ -34,7 +33,7 @@ export type ParticipantMessageBubbleProps = {
|
|
|
34
33
|
readonly perspective?: ParticipantPerspective
|
|
35
34
|
}
|
|
36
35
|
|
|
37
|
-
type BubbleContent = {
|
|
36
|
+
export type BubbleContent = {
|
|
38
37
|
readonly isMine: boolean
|
|
39
38
|
readonly authorName?: string
|
|
40
39
|
readonly text?: string
|
|
@@ -68,62 +67,31 @@ function describeItem(item: ParticipantTimelineItem, perspective?: ParticipantPe
|
|
|
68
67
|
}
|
|
69
68
|
}
|
|
70
69
|
|
|
71
|
-
function bubbleClassName(isMine: boolean, hasTail: boolean): string {
|
|
70
|
+
function bubbleClassName(isMine: boolean, hasTail: boolean, hasFailed: boolean): string {
|
|
72
71
|
const classes = ['cv-p-bubble']
|
|
73
72
|
if (isMine) classes.push('cv-p-bubble--mine')
|
|
73
|
+
if (hasFailed) classes.push('cv-p-bubble--failed')
|
|
74
74
|
if (!hasTail) classes.push('cv-p-bubble--no-tail')
|
|
75
75
|
return classes.join(' ')
|
|
76
76
|
}
|
|
77
77
|
|
|
78
|
-
export function ParticipantMessageBubble({
|
|
79
|
-
item,
|
|
80
|
-
|
|
81
|
-
confirmsRead,
|
|
82
|
-
resolveAttachmentUrl,
|
|
83
|
-
onRetry,
|
|
84
|
-
onDiscard,
|
|
85
|
-
onEdit,
|
|
86
|
-
avatar,
|
|
87
|
-
tail = true,
|
|
88
|
-
perspective,
|
|
89
|
-
}: ParticipantMessageBubbleProps) {
|
|
78
|
+
export function ParticipantMessageBubble(props: ParticipantMessageBubbleProps) {
|
|
79
|
+
const { item, labels, confirmsRead, onRetry, avatar, tail = true, perspective } = props
|
|
80
|
+
const textId = useId()
|
|
90
81
|
const content = describeItem(item, perspective)
|
|
91
|
-
const
|
|
92
|
-
const ownStatus = isMine
|
|
82
|
+
const ownStatus = content.isMine
|
|
93
83
|
? resolveOwnMessageStatus({ displayState: content.displayState, serverStatus: content.serverStatus, confirmsRead })
|
|
94
84
|
: undefined
|
|
85
|
+
const isFailed = ownStatus === 'failed'
|
|
86
|
+
const canRetry = isFailed && onRetry !== undefined
|
|
95
87
|
|
|
96
88
|
const bubble = (
|
|
97
|
-
<div className={bubbleClassName(isMine, tail)}>
|
|
98
|
-
{
|
|
99
|
-
|
|
100
|
-
) : content.authorName ? (
|
|
101
|
-
<span className="cv-p-bubble__author">{content.authorName}</span>
|
|
102
|
-
) : null}
|
|
103
|
-
{content.text ? <ParticipantMessageText text={content.text} labels={labels} /> : null}
|
|
104
|
-
{content.attachments.map((attachment) => (
|
|
105
|
-
<ParticipantAttachmentItem
|
|
106
|
-
key={attachment.id}
|
|
107
|
-
attachment={attachment}
|
|
108
|
-
resolveAttachmentUrl={resolveAttachmentUrl}
|
|
109
|
-
labels={labels}
|
|
110
|
-
/>
|
|
111
|
-
))}
|
|
112
|
-
{content.pendingFilenames.map((filename, index) => (
|
|
113
|
-
<span key={`${filename}-${index}`} className="cv-p-attachment cv-p-attachment--pending">
|
|
114
|
-
{filename}
|
|
115
|
-
</span>
|
|
116
|
-
))}
|
|
117
|
-
<span className="cv-p-bubble__meta">
|
|
118
|
-
<time className="cv-p-bubble__time" dateTime={content.createdAt}>
|
|
119
|
-
{formatTimestamp(content.createdAt)}
|
|
120
|
-
</time>
|
|
121
|
-
{ownStatus ? <OwnStatus status={ownStatus} labels={labels} onRetry={onRetry} /> : null}
|
|
122
|
-
</span>
|
|
123
|
-
{ownStatus === 'failed' ? <FailedActions labels={labels} onDiscard={onDiscard} onEdit={onEdit} /> : null}
|
|
89
|
+
<div className={bubbleClassName(content.isMine, tail, isFailed)}>
|
|
90
|
+
{canRetry ? <RetryButton labels={labels} onRetry={onRetry} describedBy={textId} /> : null}
|
|
91
|
+
<BubbleBody {...props} content={content} ownStatus={ownStatus} textId={canRetry ? textId : undefined} />
|
|
124
92
|
</div>
|
|
125
93
|
)
|
|
126
|
-
if (avatar === undefined || isMine) return bubble
|
|
94
|
+
if (avatar === undefined || content.isMine) return bubble
|
|
127
95
|
return (
|
|
128
96
|
<div className="cv-p-bubble-row">
|
|
129
97
|
<span className="cv-p-bubble-row__avatar">{avatar}</span>
|
|
@@ -45,7 +45,7 @@ describe('ParticipantMessageText - links', () => {
|
|
|
45
45
|
expect(markup).toContain('target="_blank"')
|
|
46
46
|
expect(markup).toContain('<span class="cv-p-sr-only"> (opens in a new tab)</span>')
|
|
47
47
|
expect(markup).toContain(
|
|
48
|
-
'<button type="button" class="cv-p-text__link-copy" aria-label="Copy https://a.com/?a=1&b=2">',
|
|
48
|
+
'<button type="button" class="cv-p-text__link-copy" aria-label="Copy https://a.com/?a=1&b=2" data-cv-tooltip="Copy">',
|
|
49
49
|
)
|
|
50
50
|
})
|
|
51
51
|
|
|
@@ -7,6 +7,8 @@ import type { FormatBlock } from './participantMessageFormat.types'
|
|
|
7
7
|
export type ParticipantMessageTextProps = {
|
|
8
8
|
readonly text: string | null | undefined
|
|
9
9
|
readonly labels: ParticipantTextLabels
|
|
10
|
+
/** Lets a control point at the text with aria-describedby; absent leaves the markup as it was. */
|
|
11
|
+
readonly id?: string
|
|
10
12
|
}
|
|
11
13
|
|
|
12
14
|
function renderBlock(block: FormatBlock, labels: ParticipantTextLabels, key: number) {
|
|
@@ -40,7 +42,7 @@ function renderBlock(block: FormatBlock, labels: ParticipantTextLabels, key: num
|
|
|
40
42
|
)
|
|
41
43
|
}
|
|
42
44
|
|
|
43
|
-
export function ParticipantMessageText({ text, labels }: ParticipantMessageTextProps) {
|
|
45
|
+
export function ParticipantMessageText({ text, labels, id }: ParticipantMessageTextProps) {
|
|
44
46
|
const blocks = useMemo(() => parseParticipantMessage(text), [text])
|
|
45
47
|
if (blocks.length === 0) return null
|
|
46
48
|
const [only] = blocks
|
|
@@ -48,5 +50,5 @@ export function ParticipantMessageText({ text, labels }: ParticipantMessageTextP
|
|
|
48
50
|
blocks.length === 1 && only?.kind === 'paragraph'
|
|
49
51
|
? renderInlineNodes(only.children, labels)
|
|
50
52
|
: blocks.map((block, key) => renderBlock(block, labels, key))
|
|
51
|
-
return <div className="cv-p-text">{children}</div>
|
|
53
|
+
return <div className="cv-p-text" id={id}>{children}</div>
|
|
52
54
|
}
|
|
@@ -39,6 +39,7 @@ export function ParticipantProtocolBadgeView({
|
|
|
39
39
|
type="button"
|
|
40
40
|
className="cv-p-protocol__copy"
|
|
41
41
|
aria-label={`${labels.copyProtocol} ${protocol}`}
|
|
42
|
+
data-cv-tooltip={labels.copyProtocol}
|
|
42
43
|
onClick={onCopy}
|
|
43
44
|
>
|
|
44
45
|
{isCopied ? <Check size={18} aria-hidden={true} /> : <Copy size={18} aria-hidden={true} />}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import type { Ref } from 'react'
|
|
2
|
+
|
|
3
|
+
import { Mic, Send, Square, Trash2 } from 'lucide-react'
|
|
4
|
+
|
|
5
|
+
import { ParticipantAudioPlayer } from './ParticipantAudioPlayer'
|
|
6
|
+
import type { ParticipantAudioLabels } from './participantAudioLabels'
|
|
7
|
+
|
|
8
|
+
export type RecordAudioButtonProps = {
|
|
9
|
+
readonly label: string
|
|
10
|
+
readonly isBusy: boolean
|
|
11
|
+
readonly disabled: boolean
|
|
12
|
+
readonly onClick?: () => void
|
|
13
|
+
readonly buttonRef?: Ref<HTMLButtonElement>
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function RecordAudioButton({ label, isBusy, disabled, onClick, buttonRef }: RecordAudioButtonProps) {
|
|
17
|
+
return (
|
|
18
|
+
<button
|
|
19
|
+
ref={buttonRef}
|
|
20
|
+
type="button"
|
|
21
|
+
className="cv-p-button cv-p-button--primary cv-p-composer__record"
|
|
22
|
+
aria-label={label}
|
|
23
|
+
data-cv-tooltip={label}
|
|
24
|
+
aria-busy={isBusy ? true : undefined}
|
|
25
|
+
disabled={disabled || isBusy}
|
|
26
|
+
onClick={onClick}
|
|
27
|
+
>
|
|
28
|
+
<Mic size={20} aria-hidden="true" focusable="false" />
|
|
29
|
+
</button>
|
|
30
|
+
)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function RecorderError({ message }: { readonly message: string }) {
|
|
34
|
+
return (
|
|
35
|
+
<p className="cv-p-composer__error" role="alert">
|
|
36
|
+
{message}
|
|
37
|
+
</p>
|
|
38
|
+
)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function focusOnMount(element: HTMLElement | null): void {
|
|
42
|
+
element?.focus()
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
type RecorderGroupProps = {
|
|
46
|
+
readonly labels: ParticipantAudioLabels
|
|
47
|
+
readonly onDiscard?: () => void
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function DiscardButton({ labels, onDiscard }: RecorderGroupProps) {
|
|
51
|
+
return (
|
|
52
|
+
<button
|
|
53
|
+
type="button"
|
|
54
|
+
className="cv-p-button cv-p-recorder__discard"
|
|
55
|
+
aria-label={labels.discardAudio}
|
|
56
|
+
data-cv-tooltip={labels.discardAudio}
|
|
57
|
+
onClick={onDiscard}
|
|
58
|
+
>
|
|
59
|
+
<Trash2 size={20} aria-hidden="true" focusable="false" />
|
|
60
|
+
</button>
|
|
61
|
+
)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export type RecordingGroupProps = RecorderGroupProps & {
|
|
65
|
+
readonly clockText: string
|
|
66
|
+
readonly onStop?: () => void
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function RecordingGroup({ labels, clockText, onStop, onDiscard }: RecordingGroupProps) {
|
|
70
|
+
return (
|
|
71
|
+
<div className="cv-p-recorder" role="group" aria-label={labels.recordingInProgress}>
|
|
72
|
+
<DiscardButton labels={labels} onDiscard={onDiscard} />
|
|
73
|
+
<span className="cv-p-recorder__dot" aria-hidden="true" />
|
|
74
|
+
<span className="cv-p-recorder__elapsed" role="timer">
|
|
75
|
+
{clockText}
|
|
76
|
+
</span>
|
|
77
|
+
<button
|
|
78
|
+
type="button"
|
|
79
|
+
ref={focusOnMount}
|
|
80
|
+
className="cv-p-button cv-p-button--primary cv-p-recorder__stop"
|
|
81
|
+
aria-label={labels.stopRecording}
|
|
82
|
+
data-cv-tooltip={labels.stopRecording}
|
|
83
|
+
onClick={onStop}
|
|
84
|
+
>
|
|
85
|
+
<Square size={20} aria-hidden="true" focusable="false" />
|
|
86
|
+
</button>
|
|
87
|
+
</div>
|
|
88
|
+
)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export type ReviewGroupProps = RecorderGroupProps & {
|
|
92
|
+
readonly url?: string
|
|
93
|
+
readonly audioRef?: Ref<HTMLAudioElement>
|
|
94
|
+
readonly durationMs?: number | undefined
|
|
95
|
+
readonly onSend?: () => void
|
|
96
|
+
readonly disabled?: boolean
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function ReviewGroup({ labels, url, audioRef, durationMs, onSend, onDiscard, disabled = false }: ReviewGroupProps) {
|
|
100
|
+
return (
|
|
101
|
+
<div className="cv-p-recorder cv-p-recorder--review" role="group" aria-label={labels.reviewAudio}>
|
|
102
|
+
<p className="cv-p-recorder__hint" aria-hidden="true">{labels.reviewAudio}</p>
|
|
103
|
+
<DiscardButton labels={labels} onDiscard={onDiscard} />
|
|
104
|
+
<ParticipantAudioPlayer labels={labels} url={url} durationMs={durationMs} mediaRef={audioRef} />
|
|
105
|
+
<button
|
|
106
|
+
type="button"
|
|
107
|
+
ref={focusOnMount}
|
|
108
|
+
className="cv-p-button cv-p-button--primary cv-p-recorder__send"
|
|
109
|
+
aria-label={labels.sendAudio}
|
|
110
|
+
data-cv-tooltip={labels.sendAudio}
|
|
111
|
+
disabled={disabled}
|
|
112
|
+
onClick={onSend}
|
|
113
|
+
>
|
|
114
|
+
<Send size={20} aria-hidden="true" focusable="false" />
|
|
115
|
+
</button>
|
|
116
|
+
</div>
|
|
117
|
+
)
|
|
118
|
+
}
|
|
@@ -23,7 +23,7 @@ export function ParticipantScrollToLatest({
|
|
|
23
23
|
if (!isVisible || onClick === undefined) return null
|
|
24
24
|
return (
|
|
25
25
|
<div className="cv-p-thread__latest">
|
|
26
|
-
<button type="button" className="cv-p-thread__latest-button" aria-label={label} onClick={(event) => {
|
|
26
|
+
<button type="button" className="cv-p-thread__latest-button" aria-label={label} data-cv-tooltip={label} onClick={(event) => {
|
|
27
27
|
focusScrollerOf(event.currentTarget)
|
|
28
28
|
onClick()
|
|
29
29
|
}}
|
|
@@ -1,11 +1,13 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { useRef, type ReactNode } from 'react'
|
|
2
2
|
|
|
3
3
|
import type { ParticipantConversationSummary, ParticipantSubjectRef } from '@adatechnology/conversation-contracts'
|
|
4
4
|
|
|
5
5
|
import type { ConversationChannel } from '../conversationChannel'
|
|
6
6
|
import type { QuickReply } from '../quickReplies/quickReply.types'
|
|
7
7
|
import type { ParticipantTimelineItem } from './participantMessages'
|
|
8
|
+
import { TooltipLayer } from '../Tooltip'
|
|
8
9
|
import { ParticipantComposer } from './ParticipantComposer'
|
|
10
|
+
import { useSuppressTouchTooltips } from './useSuppressTouchTooltips'
|
|
9
11
|
import { ParticipantCopyAnnouncer, ParticipantThreadLive } from './ParticipantCopyAnnouncer'
|
|
10
12
|
import type { ResolveParticipantAttachmentUrl } from './ParticipantAttachmentItem'
|
|
11
13
|
import type { ParticipantPendingActions } from './participantBubbleActions'
|
|
@@ -19,6 +21,7 @@ import { ParticipantScrollToLatest } from './ParticipantScrollToLatest'
|
|
|
19
21
|
import { ParticipantTimeline } from './ParticipantTimeline'
|
|
20
22
|
import { ParticipantThreadHeader } from './ParticipantThreadHeader'
|
|
21
23
|
import type { ParticipantSubjectGroup, ParticipantSubjectIconRenderer } from './participant.types'
|
|
24
|
+
import type { ParticipantAudioRecordingOptions } from './participantAudio.types'
|
|
22
25
|
|
|
23
26
|
export type ParticipantThreadDraft = {
|
|
24
27
|
readonly value: string
|
|
@@ -69,6 +72,10 @@ export type ParticipantThreadProps = {
|
|
|
69
72
|
readonly counterpartLabel?: string
|
|
70
73
|
/** Host slot at the end of the header bar (close, reopen...). */
|
|
71
74
|
readonly headerActions?: ReactNode
|
|
75
|
+
/** Absent draws no microphone. Present turns it on when the field is empty and the browser and channel record. */
|
|
76
|
+
readonly audioRecording?: ParticipantAudioRecordingOptions
|
|
77
|
+
/** Receives the recorded audio; the controller sends it as a message of its own. */
|
|
78
|
+
readonly onSendAudio?: (file: File) => void
|
|
72
79
|
}
|
|
73
80
|
|
|
74
81
|
function Footer({ props }: { readonly props: ParticipantThreadProps }) {
|
|
@@ -89,6 +96,23 @@ function Footer({ props }: { readonly props: ParticipantThreadProps }) {
|
|
|
89
96
|
maxLength={props.maxLength}
|
|
90
97
|
acceptedTypes={props.acceptedTypes}
|
|
91
98
|
quickReplies={props.quickReplies}
|
|
99
|
+
audioRecording={props.audioRecording}
|
|
100
|
+
onSendAudio={props.onSendAudio}
|
|
101
|
+
/>
|
|
102
|
+
)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function Header({ props }: { readonly props: ParticipantThreadProps }) {
|
|
106
|
+
return (
|
|
107
|
+
<ParticipantThreadHeader
|
|
108
|
+
conversation={props.conversation}
|
|
109
|
+
labels={props.labels}
|
|
110
|
+
subjectGroups={props.subjectGroups}
|
|
111
|
+
renderSubjectIcon={props.renderSubjectIcon}
|
|
112
|
+
onBack={props.onBack}
|
|
113
|
+
onOpenSubject={props.onOpenSubject}
|
|
114
|
+
counterpartLabel={props.counterpartLabel}
|
|
115
|
+
actions={props.headerActions}
|
|
92
116
|
/>
|
|
93
117
|
)
|
|
94
118
|
}
|
|
@@ -97,19 +121,12 @@ function ThreadBody(props: ParticipantThreadProps) {
|
|
|
97
121
|
const { conversation, labels, hasMore, onLoadOlder, renderSubjectCard } = props
|
|
98
122
|
const subjectCard = renderSubjectCard?.(conversation)
|
|
99
123
|
const loadView = resolveLoadView({ status: props.status, hasItems: props.items.length > 0 })
|
|
124
|
+
const rootRef = useRef<HTMLDivElement>(null)
|
|
125
|
+
useSuppressTouchTooltips(rootRef)
|
|
100
126
|
|
|
101
127
|
return (
|
|
102
|
-
<div className="cv-p cv-p-thread" aria-busy={loadView.isLoading}>
|
|
103
|
-
<
|
|
104
|
-
conversation={conversation}
|
|
105
|
-
labels={labels}
|
|
106
|
-
subjectGroups={props.subjectGroups}
|
|
107
|
-
renderSubjectIcon={props.renderSubjectIcon}
|
|
108
|
-
onBack={props.onBack}
|
|
109
|
-
onOpenSubject={props.onOpenSubject}
|
|
110
|
-
counterpartLabel={props.counterpartLabel}
|
|
111
|
-
actions={props.headerActions}
|
|
112
|
-
/>
|
|
128
|
+
<div className="cv-p cv-p-thread" aria-busy={loadView.isLoading} ref={rootRef}>
|
|
129
|
+
<Header props={props} />
|
|
113
130
|
{subjectCard ? <div className="cv-p-thread__subject-card">{subjectCard}</div> : null}
|
|
114
131
|
<div className="cv-p-thread__scroll" ref={props.scroll.ref} onScroll={props.scroll.onScroll} tabIndex={-1}>
|
|
115
132
|
{loadView.isLoading ? <ParticipantLoading labels={labels} /> : null}
|
|
@@ -138,6 +155,7 @@ export function ParticipantThread(props: ParticipantThreadProps) {
|
|
|
138
155
|
return (
|
|
139
156
|
<ParticipantCopyAnnouncer>
|
|
140
157
|
<ThreadBody {...props} />
|
|
158
|
+
<TooltipLayer />
|
|
141
159
|
</ParticipantCopyAnnouncer>
|
|
142
160
|
)
|
|
143
161
|
}
|
|
@@ -102,7 +102,7 @@ describe('copy button as an icon', () => {
|
|
|
102
102
|
const button = markup.slice(markup.indexOf('<button'), markup.indexOf('</button>'))
|
|
103
103
|
expect(button).toContain('<svg')
|
|
104
104
|
expect(button.replace(/<[^>]+>/g, '')).toBe('')
|
|
105
|
-
expect(markup.replace(/aria-label="[^"]*"/g, '')).not.toContain('Copy protocol')
|
|
105
|
+
expect(markup.replace(/(aria-label|data-cv-tooltip)="[^"]*"/g, '')).not.toContain('Copy protocol')
|
|
106
106
|
})
|
|
107
107
|
|
|
108
108
|
it('shows the short confirmation next to the icon while copied', () => {
|