@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
@@ -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('shows discard and edit on a failed pending only when the handlers exist', () => {
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-bubble__action')
128
- expect(both).toContain('>Discard<')
129
- expect(both).toContain('>Edit<')
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 { ReactNode } from 'react'
1
+ import { useId, type ReactNode } from 'react'
2
2
 
3
3
  import type { MessageDeliveryStatus, ParticipantAttachment } from '@adatechnology/conversation-contracts'
4
4
 
5
- import { formatTimestamp } from '../lib/format'
6
- import { FailedActions, OwnStatus } from './ParticipantBubbleStatus'
7
- import { ParticipantMessageText } from './ParticipantMessageText'
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 button. */
24
+ /** Absent means no discard item in the failed-message menu. */
26
25
  readonly onDiscard?: () => void
27
- /** Absent means no edit button. */
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
- labels,
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 { isMine } = content
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
- {isMine ? (
99
- <span className="cv-p-sr-only">{labels.me}</span>
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&amp;b=2">',
48
+ '<button type="button" class="cv-p-text__link-copy" aria-label="Copy https://a.com/?a=1&amp;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 { ReactNode } from 'react'
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
- <ParticipantThreadHeader
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', () => {
@@ -104,6 +104,7 @@ function LoadedThread(props: LoadedThreadProps) {
104
104
  avatars={props.avatars}
105
105
  renderAuthorAvatar={props.renderAuthorAvatar}
106
106
  tail={props.tail}
107
+ audioRecording={props.audioRecording}
107
108
  />
108
109
  )
109
110
  }