@adatechnology/conversations-ui 0.7.0 → 0.9.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 (81) hide show
  1. package/README.md +96 -1
  2. package/dist/{ConversationSimulatorPanel-CrfFjiWh.d.ts → ConversationSimulatorPanel-q-wVgBCN.d.ts} +1 -1
  3. package/dist/{chunk-HMCOTZAX.js → chunk-NSOAUYNE.js} +33 -28
  4. package/dist/chunk-ZSOD5AQT.js +19 -0
  5. package/dist/flows/index.js +4 -2
  6. package/dist/index.d.ts +4 -4
  7. package/dist/index.js +5 -3
  8. package/dist/participant/index.d.ts +48 -3
  9. package/dist/participant/index.js +782 -347
  10. package/dist/preview/index.d.ts +3 -3
  11. package/dist/{quickReply.types-B4gmZsSr.d.ts → quickReply.types-DzWH3NYV.d.ts} +1 -1
  12. package/dist/styles.css +173 -52
  13. package/package.json +2 -1
  14. package/src/Tooltip.tsx +5 -12
  15. package/src/TooltipDom.test.tsx +78 -0
  16. package/src/domHarness.test-helper.ts +53 -0
  17. package/src/participant/ConversationThread.test.tsx +165 -0
  18. package/src/participant/ConversationThread.tsx +117 -0
  19. package/src/participant/ParticipantBackButton.tsx +1 -1
  20. package/src/participant/ParticipantBubbleBody.tsx +54 -0
  21. package/src/participant/ParticipantBubbleStatus.tsx +22 -28
  22. package/src/participant/ParticipantChannelBadges.tsx +10 -2
  23. package/src/participant/ParticipantComposer.tsx +1 -1
  24. package/src/participant/ParticipantComposerControls.tsx +2 -0
  25. package/src/participant/ParticipantCopyableToken.tsx +1 -0
  26. package/src/participant/ParticipantFailedMenu.tsx +40 -0
  27. package/src/participant/ParticipantFailedMenuList.tsx +41 -0
  28. package/src/participant/ParticipantMessageBubble.test.tsx +6 -6
  29. package/src/participant/ParticipantMessageBubble.tsx +26 -54
  30. package/src/participant/ParticipantMessageText.test.tsx +1 -1
  31. package/src/participant/ParticipantMessageText.tsx +4 -2
  32. package/src/participant/ParticipantProtocolBadge.tsx +1 -0
  33. package/src/participant/ParticipantScrollToLatest.tsx +1 -1
  34. package/src/participant/ParticipantThread.tsx +30 -10
  35. package/src/participant/ParticipantThreadHeader.test.tsx +1 -1
  36. package/src/participant/ParticipantThreadHeader.tsx +10 -0
  37. package/src/participant/ParticipantThreadScreen.tsx +6 -46
  38. package/src/participant/ParticipantTimeline.tsx +6 -3
  39. package/src/participant/conversationThreadApi.test.ts +27 -0
  40. package/src/participant/conversationThreadApi.ts +24 -0
  41. package/src/participant/conversationThreadCss.test.ts +116 -0
  42. package/src/participant/conversationThreadSummary.ts +34 -0
  43. package/src/participant/failedMenuKeyboard.ts +29 -0
  44. package/src/participant/index.ts +3 -0
  45. package/src/participant/participantContrast.test-helper.ts +116 -0
  46. package/src/participant/participantFailedMenuDom.test.tsx +154 -0
  47. package/src/participant/participantFailedMenuState.test.ts +61 -0
  48. package/src/participant/participantFailedMenuState.ts +57 -0
  49. package/src/participant/participantFailedMessage.test.tsx +83 -0
  50. package/src/participant/participantGolden.test.tsx +46 -0
  51. package/src/participant/participantGoldenScenarios.test-helper.tsx +57 -0
  52. package/src/participant/participantLabels.ts +6 -0
  53. package/src/participant/participantMessages.ts +4 -3
  54. package/src/participant/participantNewMessages.test.ts +21 -0
  55. package/src/participant/participantNewMessages.ts +8 -3
  56. package/src/participant/participantPerspective.test.ts +67 -0
  57. package/src/participant/participantPerspective.ts +50 -0
  58. package/src/participant/participantPerspectiveRender.test.tsx +94 -0
  59. package/src/participant/participantThemeContrast.test.ts +106 -0
  60. package/src/participant/participantTooltips.test.tsx +74 -0
  61. package/src/participant/test-fixtures/conversations-list.golden.html +1 -0
  62. package/src/participant/test-fixtures/conversations-selected.golden.html +1 -0
  63. package/src/participant/test-fixtures/thread-avatars-initials.golden.html +1 -0
  64. package/src/participant/test-fixtures/thread-closed.golden.html +1 -0
  65. package/src/participant/test-fixtures/thread-default.golden.html +1 -0
  66. package/src/participant/test-fixtures/thread-error.golden.html +1 -0
  67. package/src/participant/test-fixtures/thread-loading.golden.html +1 -0
  68. package/src/participant/test-fixtures/thread-plain.golden.html +1 -0
  69. package/src/participant/test-fixtures/thread-tail-off.golden.html +1 -0
  70. package/src/participant/useFailedMenuEffects.ts +59 -0
  71. package/src/participant/useParticipantConversationView.ts +3 -0
  72. package/src/participant/useParticipantFailedMenu.ts +60 -0
  73. package/src/participant/useParticipantThread.ts +13 -2
  74. package/src/participant/useParticipantThreadController.ts +112 -0
  75. package/src/participant/useParticipantThreadScroll.ts +3 -1
  76. package/src/participant/useParticipantUnread.test.tsx +40 -0
  77. package/src/participant/useParticipantUnread.ts +38 -0
  78. package/src/participant/useSuppressTouchTooltips.ts +38 -0
  79. package/src/styles.css +43 -17
  80. package/src/tooltipLayerRegistry.test.ts +56 -0
  81. package/src/tooltipLayerRegistry.ts +34 -0
@@ -0,0 +1,117 @@
1
+ import { useCallback, useMemo, useReducer, useRef, type ReactNode } from 'react'
2
+
3
+ import type { ParticipantConversationSummary, ParticipantSubjectRef } from '@adatechnology/conversation-contracts'
4
+
5
+ import type { ConversationChannel } from '../conversationChannel'
6
+ import type { QuickReply } from '../quickReplies/quickReply.types'
7
+ import type { ConversationsTheme } from '../types'
8
+ import type { ParticipantAuthorAvatarRenderer } from './ParticipantAuthorAvatar'
9
+ import { ParticipantThread } from './ParticipantThread'
10
+ import { toParticipantApi, type ConversationThreadApi } from './conversationThreadApi'
11
+ import { buildThreadSummary } from './conversationThreadSummary'
12
+ import type { ParticipantSubjectGroup, ParticipantSubjectIconRenderer } from './participant.types'
13
+ import type { ParticipantPendingMessage } from './participantApi.types'
14
+ import { draftKey, type ParticipantDrafts } from './participantDrafts'
15
+ import { DEFAULT_PARTICIPANT_CONVERSATIONS_LABELS, type ParticipantConversationsLabels } from './participantLabels'
16
+ import type { ParticipantPerspective } from './participantPerspective'
17
+ import { participantSendStatesReducer } from './participantSendStates'
18
+ import { buildParticipantThemeStyle } from './participantTheme'
19
+ import { useParticipantThreadController, type ParticipantThreadCoreProps } from './useParticipantThreadController'
20
+
21
+ export type ConversationThreadProps = {
22
+ readonly api: ConversationThreadApi
23
+ readonly subject: ParticipantSubjectRef
24
+ /** `participant` (default) is the outside party; `operator` is the company, which owns the outbound messages. */
25
+ readonly perspective?: ParticipantPerspective
26
+ readonly title: string
27
+ readonly protocol?: string
28
+ readonly channels?: ParticipantConversationSummary['channels']
29
+ /** `closed` removes the composer and shows `labels.closedNotice`. */
30
+ readonly status?: 'open' | 'closed'
31
+ /** Short line under the title, written by the host (for example "Participant: Ana"). */
32
+ readonly counterpartLabel?: string
33
+ /** Slot at the end of the header bar for the host's own actions (close, reopen...). The package knows none of them. */
34
+ readonly headerActions?: ReactNode
35
+ /** Absent draws no back arrow. */
36
+ readonly onBack?: () => void
37
+ readonly renderSubjectIcon?: ParticipantSubjectIconRenderer
38
+ readonly subjectGroups?: readonly ParticipantSubjectGroup[]
39
+ /** Chips above the field: tapping one fills the text, it never sends. */
40
+ readonly quickReplies?: readonly QuickReply[]
41
+ readonly labels?: Partial<ParticipantConversationsLabels>
42
+ readonly theme?: ConversationsTheme
43
+ readonly className?: string
44
+ readonly locale?: string
45
+ readonly channel?: ConversationChannel
46
+ readonly pendingMessages?: readonly ParticipantPendingMessage[]
47
+ readonly onRetryPending?: (clientMessageId: string) => void
48
+ /** Tells the host the other side's messages were marked read, to refresh its own badges. */
49
+ readonly onMarkedRead?: (subject: ParticipantSubjectRef) => void
50
+ readonly tail?: boolean
51
+ readonly avatars?: 'initials'
52
+ readonly renderAuthorAvatar?: ParticipantAuthorAvatarRenderer
53
+ }
54
+
55
+ function useConversationThreadCore(props: ConversationThreadProps): ParticipantThreadCoreProps {
56
+ const { perspective = 'participant' } = props
57
+ const { subjectType, subjectId } = props.subject
58
+ const subject = useMemo<ParticipantSubjectRef>(() => ({ subjectType, subjectId }), [subjectType, subjectId])
59
+ const onMarkedReadRef = useRef(props.onMarkedRead)
60
+ onMarkedReadRef.current = props.onMarkedRead
61
+ const handleMarkedRead = useCallback((marked: ParticipantSubjectRef) => onMarkedReadRef.current?.(marked), [])
62
+ const api = useMemo(() => toParticipantApi(props.api), [props.api])
63
+ const draftsRef = useRef<ParticipantDrafts>(new Map())
64
+ const [sendStates, dispatchSendStates] = useReducer(participantSendStatesReducer, new Map())
65
+ const labels = useMemo(() => ({ ...DEFAULT_PARTICIPANT_CONVERSATIONS_LABELS, ...props.labels }), [props.labels])
66
+ const { title, protocol, channels, status } = props
67
+ const conversation = useMemo(
68
+ () => buildThreadSummary({ subject, title, protocol, channels, status }),
69
+ [subject, title, protocol, channels, status],
70
+ )
71
+ return useParticipantThreadController({
72
+ api,
73
+ subject,
74
+ conversation,
75
+ labels,
76
+ draftsRef,
77
+ sendStates,
78
+ dispatchSendStates,
79
+ onMarkedRead: handleMarkedRead,
80
+ channel: props.channel,
81
+ locale: props.locale,
82
+ pendingMessages: props.pendingMessages,
83
+ onRetryPending: props.onRetryPending,
84
+ perspective,
85
+ })
86
+ }
87
+
88
+ function ConversationThreadBody(props: ConversationThreadProps) {
89
+ const core = useConversationThreadCore(props)
90
+
91
+ return (
92
+ <div
93
+ className={['cv-p-root', props.className].filter(Boolean).join(' ')}
94
+ style={buildParticipantThemeStyle(props.theme)}
95
+ >
96
+ <div className="cv-p-root__screen">
97
+ <ParticipantThread
98
+ {...core}
99
+ onBack={props.onBack}
100
+ quickReplies={props.quickReplies}
101
+ subjectGroups={props.subjectGroups}
102
+ renderSubjectIcon={props.renderSubjectIcon}
103
+ avatars={props.avatars}
104
+ renderAuthorAvatar={props.renderAuthorAvatar}
105
+ tail={props.tail}
106
+ counterpartLabel={props.counterpartLabel}
107
+ headerActions={props.headerActions}
108
+ />
109
+ </div>
110
+ </div>
111
+ )
112
+ }
113
+
114
+ /** One conversation about one subject, drawn like the participant app and seen from either side. */
115
+ export function ConversationThread(props: ConversationThreadProps) {
116
+ return <ConversationThreadBody key={draftKey(props.subject)} {...props} />
117
+ }
@@ -7,7 +7,7 @@ export type ParticipantBackButtonProps = {
7
7
 
8
8
  export function ParticipantBackButton({ label, onBack }: ParticipantBackButtonProps) {
9
9
  return (
10
- <button type="button" className="cv-p-thread__back" aria-label={label} onClick={onBack}>
10
+ <button type="button" className="cv-p-thread__back" aria-label={label} data-cv-tooltip={label} onClick={onBack}>
11
11
  <ArrowLeft size={24} aria-hidden="true" focusable="false" />
12
12
  </button>
13
13
  )
@@ -0,0 +1,54 @@
1
+ import { formatTimestamp } from '../lib/format'
2
+ import { ParticipantAttachmentItem, type ResolveParticipantAttachmentUrl } from './ParticipantAttachmentItem'
3
+ import { OwnStatus } from './ParticipantBubbleStatus'
4
+ import { ParticipantFailedMenu } from './ParticipantFailedMenu'
5
+ import type { BubbleContent } from './ParticipantMessageBubble'
6
+ import { ParticipantMessageText } from './ParticipantMessageText'
7
+ import type { ParticipantConversationsLabels } from './participantLabels'
8
+ import type { ParticipantOwnMessageStatus } from './participantMessages'
9
+
10
+ type BubbleBodyProps = {
11
+ readonly content: BubbleContent
12
+ readonly labels: ParticipantConversationsLabels
13
+ readonly ownStatus?: ParticipantOwnMessageStatus
14
+ readonly textId?: string
15
+ readonly resolveAttachmentUrl: ResolveParticipantAttachmentUrl
16
+ readonly onRetry?: () => void
17
+ readonly onDiscard?: () => void
18
+ readonly onEdit?: () => void
19
+ }
20
+
21
+ function BubbleHeading({ content, labels }: Pick<BubbleBodyProps, 'content' | 'labels'>) {
22
+ if (content.isMine) return <span className="cv-p-sr-only">{labels.me}</span>
23
+ if (!content.authorName) return null
24
+ return <span className="cv-p-bubble__author">{content.authorName}</span>
25
+ }
26
+
27
+ export function BubbleBody({ content, labels, ownStatus, textId, resolveAttachmentUrl, onRetry, onDiscard, onEdit }: BubbleBodyProps) {
28
+ return (
29
+ <>
30
+ <BubbleHeading content={content} labels={labels} />
31
+ {content.text ? <ParticipantMessageText text={content.text} labels={labels} id={textId} /> : null}
32
+ {content.attachments.map((attachment) => (
33
+ <ParticipantAttachmentItem
34
+ key={attachment.id}
35
+ attachment={attachment}
36
+ resolveAttachmentUrl={resolveAttachmentUrl}
37
+ labels={labels}
38
+ />
39
+ ))}
40
+ {content.pendingFilenames.map((filename, index) => (
41
+ <span key={`${filename}-${index}`} className="cv-p-attachment cv-p-attachment--pending">
42
+ {filename}
43
+ </span>
44
+ ))}
45
+ <span className="cv-p-bubble__meta">
46
+ <time className="cv-p-bubble__time" dateTime={content.createdAt}>
47
+ {formatTimestamp(content.createdAt)}
48
+ </time>
49
+ {ownStatus ? <OwnStatus status={ownStatus} labels={labels} onRetry={onRetry} /> : null}
50
+ {ownStatus === 'failed' ? <ParticipantFailedMenu labels={labels} onEdit={onEdit} onDiscard={onDiscard} /> : null}
51
+ </span>
52
+ </>
53
+ )
54
+ }
@@ -24,44 +24,38 @@ export function OwnStatus({ status, labels, onRetry }: OwnStatusProps) {
24
24
  const ticksStatus = status === 'sending' ? 'queued' : status
25
25
  const isVisibleText = status === 'sending' || status === 'queued' || status === 'failed'
26
26
 
27
- if (status === 'failed' && onRetry) {
28
- return (
29
- <button type="button" className="cv-p-bubble__retry" onClick={onRetry}>
30
- <StatusTicks status="failed" appearance="stylesheet" />
31
- <span>{labels.statusFailed}</span>
32
- </button>
33
- )
34
- }
35
-
36
27
  const text = status === 'failed' ? labels.statusFailedShort : statusText(status, labels)
28
+ const isTextVisible = isVisibleText && !(status === 'failed' && onRetry)
37
29
  return (
38
30
  <span className="cv-p-bubble__status">
39
- <StatusTicks status={ticksStatus} appearance="stylesheet" />
40
- <span className={isVisibleText ? 'cv-p-bubble__status-text' : 'cv-p-sr-only'}>{text}</span>
31
+ <StatusTicks status={ticksStatus} appearance="stylesheet" title={isTextVisible ? undefined : text} />
32
+ <span className={isTextVisible ? 'cv-p-bubble__status-text' : 'cv-p-sr-only'}>{text}</span>
41
33
  </span>
42
34
  )
43
35
  }
44
36
 
45
- type FailedActionsProps = {
37
+ type RetryButtonProps = {
46
38
  readonly labels: ParticipantConversationsLabels
47
- readonly onDiscard?: () => void
48
- readonly onEdit?: () => void
39
+ readonly onRetry: () => void
40
+ /** Id of the message text, read after the name so the button says which message it resends. */
41
+ readonly describedBy?: string
49
42
  }
50
43
 
51
- export function FailedActions({ labels, onDiscard, onEdit }: FailedActionsProps) {
52
- if (!onDiscard && !onEdit) return null
44
+ export function RetryButton({ labels, onRetry, describedBy }: RetryButtonProps) {
53
45
  return (
54
- <span className="cv-p-bubble__actions">
55
- {onEdit ? (
56
- <button type="button" className="cv-p-bubble__action" onClick={onEdit}>
57
- {labels.edit}
58
- </button>
59
- ) : null}
60
- {onDiscard ? (
61
- <button type="button" className="cv-p-bubble__action" onClick={onDiscard}>
62
- {labels.discard}
63
- </button>
64
- ) : null}
65
- </span>
46
+ <button
47
+ type="button"
48
+ className="cv-p-failed-retry"
49
+ aria-label={labels.statusFailed}
50
+ aria-describedby={describedBy}
51
+ data-cv-tooltip={labels.statusFailed}
52
+ onClick={onRetry}
53
+ >
54
+ <span className="cv-p-failed-retry__icon">
55
+ <svg viewBox="0 0 16 16" width="14" height="14" aria-hidden="true" focusable="false">
56
+ <path d="M13 8a5 5 0 1 1-1.6-3.7M13 2.5v3h-3" fill="none" stroke="currentColor" strokeWidth="1.5" />
57
+ </svg>
58
+ </span>
59
+ </button>
66
60
  )
67
61
  }
@@ -59,7 +59,11 @@ export function ParticipantChannelBadges({
59
59
  <span className="cv-p-channels">
60
60
  <span className="cv-p-sr-only">{`${labels.channelsGroup}: `}</span>
61
61
  {descriptors.map((descriptor, index) => (
62
- <span key={descriptor.channel} className={`cv-p-channel cv-p-channel--${descriptor.channel}`}>
62
+ <span
63
+ key={descriptor.channel}
64
+ className={`cv-p-channel cv-p-channel--${descriptor.channel}`}
65
+ data-cv-tooltip={resolveParticipantChannelLabel(descriptor, labels)}
66
+ >
63
67
  <BadgeContent
64
68
  descriptor={descriptor}
65
69
  labels={labels}
@@ -75,7 +79,11 @@ export function ParticipantChannelBadges({
75
79
  return (
76
80
  <ul className="cv-p-channels" aria-label={labels.channelsGroup}>
77
81
  {descriptors.map((descriptor) => (
78
- <li key={descriptor.channel} className={`cv-p-channel cv-p-channel--${descriptor.channel}`}>
82
+ <li
83
+ key={descriptor.channel}
84
+ className={`cv-p-channel cv-p-channel--${descriptor.channel}`}
85
+ data-cv-tooltip={resolveParticipantChannelLabel(descriptor, labels)}
86
+ >
79
87
  <BadgeContent descriptor={descriptor} labels={labels} separator="" iconSize={iconSize} />
80
88
  </li>
81
89
  ))}
@@ -60,7 +60,7 @@ function FileList({ files, labels, onRemove }: FileListProps) {
60
60
  <li key={`${file.name}-${file.size}-${index}`} className="cv-p-files__item">
61
61
  <span className="cv-p-files__name">{file.name}</span>
62
62
  <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)}>
63
+ <button type="button" className="cv-p-files__remove" aria-label={`${labels.removeAttachment}: ${file.name}`} data-cv-tooltip={labels.removeAttachment} onClick={() => onRemove(file)}>
64
64
  <span aria-hidden="true">×</span>
65
65
  <span className="cv-p-sr-only">{labels.removeAttachment}</span>
66
66
  </button>
@@ -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" />
@@ -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,12 +1,12 @@
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'
9
+ import type { ParticipantPerspective } from './participantPerspective'
10
10
  import {
11
11
  isOwnMessage,
12
12
  resolveOwnMessageStatus,
@@ -19,19 +19,21 @@ export type ParticipantMessageBubbleProps = {
19
19
  readonly labels: ParticipantConversationsLabels
20
20
  readonly confirmsRead: boolean
21
21
  readonly resolveAttachmentUrl: ResolveParticipantAttachmentUrl
22
- /** Absent means no retry button, even when the message failed. */
22
+ /** Absent means no retry button beside the bubble, even when the message failed. */
23
23
  readonly onRetry?: () => void
24
- /** Absent means no discard button. */
24
+ /** Absent means no discard item in the failed-message menu. */
25
25
  readonly onDiscard?: () => void
26
- /** Absent means no edit button. */
26
+ /** Absent means no edit item in the failed-message menu. */
27
27
  readonly onEdit?: () => void
28
28
  /** Opt-in slot to the left of a received bubble; null reserves the space, absent changes nothing. */
29
29
  readonly avatar?: ReactNode
30
30
  /** Speech-bubble tail on the bottom corner; false removes it. Default true. */
31
31
  readonly tail?: boolean
32
+ /** Who is looking; absent is the participant. The operator owns the outbound messages. */
33
+ readonly perspective?: ParticipantPerspective
32
34
  }
33
35
 
34
- type BubbleContent = {
36
+ export type BubbleContent = {
35
37
  readonly isMine: boolean
36
38
  readonly authorName?: string
37
39
  readonly text?: string
@@ -42,11 +44,11 @@ type BubbleContent = {
42
44
  readonly serverStatus?: MessageDeliveryStatus
43
45
  }
44
46
 
45
- function describeItem(item: ParticipantTimelineItem): BubbleContent {
47
+ function describeItem(item: ParticipantTimelineItem, perspective?: ParticipantPerspective): BubbleContent {
46
48
  if (item.kind === 'server') {
47
49
  const { message } = item
48
50
  return {
49
- isMine: isOwnMessage(message),
51
+ isMine: isOwnMessage(message, perspective),
50
52
  authorName: message.authorName ?? undefined,
51
53
  text: message.text ?? undefined,
52
54
  createdAt: message.createdAt,
@@ -65,61 +67,31 @@ function describeItem(item: ParticipantTimelineItem): BubbleContent {
65
67
  }
66
68
  }
67
69
 
68
- function bubbleClassName(isMine: boolean, hasTail: boolean): string {
70
+ function bubbleClassName(isMine: boolean, hasTail: boolean, hasFailed: boolean): string {
69
71
  const classes = ['cv-p-bubble']
70
72
  if (isMine) classes.push('cv-p-bubble--mine')
73
+ if (hasFailed) classes.push('cv-p-bubble--failed')
71
74
  if (!hasTail) classes.push('cv-p-bubble--no-tail')
72
75
  return classes.join(' ')
73
76
  }
74
77
 
75
- export function ParticipantMessageBubble({
76
- item,
77
- labels,
78
- confirmsRead,
79
- resolveAttachmentUrl,
80
- onRetry,
81
- onDiscard,
82
- onEdit,
83
- avatar,
84
- tail = true,
85
- }: ParticipantMessageBubbleProps) {
86
- const content = describeItem(item)
87
- const { isMine } = content
88
- const ownStatus = isMine
78
+ export function ParticipantMessageBubble(props: ParticipantMessageBubbleProps) {
79
+ const { item, labels, confirmsRead, onRetry, avatar, tail = true, perspective } = props
80
+ const textId = useId()
81
+ const content = describeItem(item, perspective)
82
+ const ownStatus = content.isMine
89
83
  ? resolveOwnMessageStatus({ displayState: content.displayState, serverStatus: content.serverStatus, confirmsRead })
90
84
  : undefined
85
+ const isFailed = ownStatus === 'failed'
86
+ const canRetry = isFailed && onRetry !== undefined
91
87
 
92
88
  const bubble = (
93
- <div className={bubbleClassName(isMine, tail)}>
94
- {isMine ? (
95
- <span className="cv-p-sr-only">{labels.me}</span>
96
- ) : content.authorName ? (
97
- <span className="cv-p-bubble__author">{content.authorName}</span>
98
- ) : null}
99
- {content.text ? <ParticipantMessageText text={content.text} labels={labels} /> : null}
100
- {content.attachments.map((attachment) => (
101
- <ParticipantAttachmentItem
102
- key={attachment.id}
103
- attachment={attachment}
104
- resolveAttachmentUrl={resolveAttachmentUrl}
105
- labels={labels}
106
- />
107
- ))}
108
- {content.pendingFilenames.map((filename, index) => (
109
- <span key={`${filename}-${index}`} className="cv-p-attachment cv-p-attachment--pending">
110
- {filename}
111
- </span>
112
- ))}
113
- <span className="cv-p-bubble__meta">
114
- <time className="cv-p-bubble__time" dateTime={content.createdAt}>
115
- {formatTimestamp(content.createdAt)}
116
- </time>
117
- {ownStatus ? <OwnStatus status={ownStatus} labels={labels} onRetry={onRetry} /> : null}
118
- </span>
119
- {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} />
120
92
  </div>
121
93
  )
122
- if (avatar === undefined || isMine) return bubble
94
+ if (avatar === undefined || content.isMine) return bubble
123
95
  return (
124
96
  <div className="cv-p-bubble-row">
125
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} />}
@@ -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
  }}