@adatechnology/conversations-ui 0.6.1 → 0.8.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 (119) hide show
  1. package/README.md +221 -19
  2. package/dist/{ConversationSimulatorPanel-CrfFjiWh.d.ts → ConversationSimulatorPanel-q-wVgBCN.d.ts} +1 -1
  3. package/dist/{chunk-HOKNKXIT.js → chunk-HMCOTZAX.js} +1 -1
  4. package/dist/{chunk-FAQLC3TX.js → chunk-L3OFAQPS.js} +2 -4
  5. package/dist/{chunk-CBYEHPX6.js → chunk-PJNR6UMN.js} +63 -1
  6. package/dist/flows/index.js +3 -5
  7. package/dist/index.d.ts +4 -4
  8. package/dist/index.js +834 -764
  9. package/dist/participant/index.d.ts +62 -3
  10. package/dist/participant/index.js +1444 -394
  11. package/dist/preview/index.d.ts +3 -3
  12. package/dist/preview/index.js +2 -3
  13. package/dist/{quickReply.types-B4gmZsSr.d.ts → quickReply.types-DzWH3NYV.d.ts} +1 -1
  14. package/dist/styles.css +629 -20
  15. package/package.json +3 -3
  16. package/src/participant/ConversationThread.test.tsx +165 -0
  17. package/src/participant/ConversationThread.tsx +117 -0
  18. package/src/participant/ParticipantAuthorAvatar.test.tsx +30 -0
  19. package/src/participant/ParticipantAuthorAvatar.tsx +24 -0
  20. package/src/participant/ParticipantBackButton.tsx +14 -0
  21. package/src/participant/ParticipantBubbleStatus.tsx +67 -0
  22. package/src/participant/ParticipantChannelBadges.tsx +13 -4
  23. package/src/participant/ParticipantComposer.tsx +8 -40
  24. package/src/participant/ParticipantComposerControls.tsx +83 -0
  25. package/src/participant/ParticipantConversationAvatar.tsx +13 -0
  26. package/src/participant/ParticipantConversations.tsx +7 -0
  27. package/src/participant/ParticipantConversationsOptions.test.tsx +87 -0
  28. package/src/participant/ParticipantCopyAnnouncer.tsx +48 -0
  29. package/src/participant/ParticipantCopyableToken.tsx +51 -0
  30. package/src/participant/ParticipantDayDivider.tsx +18 -0
  31. package/src/participant/ParticipantInbox.tsx +3 -1
  32. package/src/participant/ParticipantInboxRow.tsx +6 -4
  33. package/src/participant/ParticipantMessageBubble.test.tsx +52 -5
  34. package/src/participant/ParticipantMessageBubble.tsx +32 -84
  35. package/src/participant/ParticipantMessageNodes.tsx +54 -0
  36. package/src/participant/ParticipantMessageText.test.tsx +106 -0
  37. package/src/participant/ParticipantMessageText.tsx +52 -0
  38. package/src/participant/ParticipantProtocolBadge.tsx +15 -7
  39. package/src/participant/ParticipantProtocolView.test.tsx +4 -6
  40. package/src/participant/ParticipantScrollToLatest.test.tsx +153 -0
  41. package/src/participant/ParticipantScrollToLatest.tsx +40 -0
  42. package/src/participant/ParticipantThread.test.tsx +37 -0
  43. package/src/participant/ParticipantThread.tsx +54 -95
  44. package/src/participant/ParticipantThreadAvatarSequence.test.tsx +65 -0
  45. package/src/participant/ParticipantThreadHeader.test.tsx +181 -0
  46. package/src/participant/ParticipantThreadHeader.tsx +83 -0
  47. package/src/participant/ParticipantThreadScreen.tsx +30 -56
  48. package/src/participant/ParticipantTimeline.tsx +106 -0
  49. package/src/participant/conversationThreadApi.test.ts +27 -0
  50. package/src/participant/conversationThreadApi.ts +24 -0
  51. package/src/participant/conversationThreadCss.test.ts +116 -0
  52. package/src/participant/conversationThreadSummary.ts +34 -0
  53. package/src/participant/index.ts +4 -0
  54. package/src/participant/participantAutoGrow.test.ts +43 -0
  55. package/src/participant/participantAutoGrow.ts +12 -0
  56. package/src/participant/participantAvatar.test.ts +74 -0
  57. package/src/participant/participantAvatar.ts +48 -0
  58. package/src/participant/participantBubbleTail.test.tsx +107 -0
  59. package/src/participant/participantChatDesign.test.tsx +198 -0
  60. package/src/participant/participantContrast.test-helper.ts +116 -0
  61. package/src/participant/participantCopyAnnouncement.test.ts +52 -0
  62. package/src/participant/participantCopyAnnouncement.ts +37 -0
  63. package/src/participant/participantCss.test-helper.ts +34 -0
  64. package/src/participant/participantDay.test.ts +22 -0
  65. package/src/participant/participantDay.ts +8 -0
  66. package/src/participant/participantDocuments.ts +45 -0
  67. package/src/participant/participantGolden.test.tsx +46 -0
  68. package/src/participant/participantGoldenScenarios.test-helper.tsx +57 -0
  69. package/src/participant/participantHeaderA11y.test.tsx +85 -0
  70. package/src/participant/participantHeaderDesign.test.tsx +111 -0
  71. package/src/participant/participantLabels.ts +11 -0
  72. package/src/participant/participantMessageAtoms.ts +53 -0
  73. package/src/participant/participantMessageCopyables.test.ts +141 -0
  74. package/src/participant/participantMessageCopyables.ts +76 -0
  75. package/src/participant/participantMessageFormat.test-helper.ts +21 -0
  76. package/src/participant/participantMessageFormat.test.ts +177 -0
  77. package/src/participant/participantMessageFormat.ts +135 -0
  78. package/src/participant/participantMessageFormat.types.ts +22 -0
  79. package/src/participant/participantMessageInline.test.ts +82 -0
  80. package/src/participant/participantMessageInline.ts +99 -0
  81. package/src/participant/participantMessageLinks.test.ts +100 -0
  82. package/src/participant/participantMessageLinks.ts +54 -0
  83. package/src/participant/participantMessageTextStyle.test.ts +137 -0
  84. package/src/participant/participantMessages.ts +4 -3
  85. package/src/participant/participantNewMessages.test.ts +21 -0
  86. package/src/participant/participantNewMessages.ts +8 -3
  87. package/src/participant/participantPerspective.test.ts +67 -0
  88. package/src/participant/participantPerspective.ts +50 -0
  89. package/src/participant/participantPerspectiveRender.test.tsx +94 -0
  90. package/src/participant/participantScrollFocus.test.ts +22 -0
  91. package/src/participant/participantScrollFocus.ts +10 -0
  92. package/src/participant/participantScrollView.test.ts +74 -0
  93. package/src/participant/participantScrollView.ts +38 -0
  94. package/src/participant/participantScrollbars.test.ts +156 -0
  95. package/src/participant/participantSubjectIcon.test.tsx +27 -0
  96. package/src/participant/participantSubjectIcon.ts +26 -0
  97. package/src/participant/participantThemeContrast.test.ts +98 -0
  98. package/src/participant/test-fixtures/conversations-list.golden.html +1 -0
  99. package/src/participant/test-fixtures/conversations-selected.golden.html +1 -0
  100. package/src/participant/test-fixtures/thread-avatars-initials.golden.html +1 -0
  101. package/src/participant/test-fixtures/thread-closed.golden.html +1 -0
  102. package/src/participant/test-fixtures/thread-default.golden.html +1 -0
  103. package/src/participant/test-fixtures/thread-error.golden.html +1 -0
  104. package/src/participant/test-fixtures/thread-loading.golden.html +1 -0
  105. package/src/participant/test-fixtures/thread-plain.golden.html +1 -0
  106. package/src/participant/test-fixtures/thread-tail-off.golden.html +1 -0
  107. package/src/participant/useParticipantConversationView.ts +3 -0
  108. package/src/participant/useParticipantCopyFeedback.ts +33 -0
  109. package/src/participant/useParticipantThread.ts +13 -2
  110. package/src/participant/useParticipantThreadController.ts +112 -0
  111. package/src/participant/useParticipantThreadScroll.ts +13 -3
  112. package/src/participant/useParticipantUnread.test.tsx +40 -0
  113. package/src/participant/useParticipantUnread.ts +38 -0
  114. package/src/participant/useRevealActiveChip.ts +20 -0
  115. package/src/participant/useScrollAwayState.ts +24 -0
  116. package/src/participant/useStickToBottom.ts +16 -2
  117. package/src/styles.css +151 -23
  118. package/dist/chunk-MRVFFJEH.js +0 -64
  119. package/dist/chunk-OVFCLRQQ.js +0 -84
@@ -5,6 +5,7 @@ import type { ParticipantConversationSummary, ParticipantSubjectRef } from '@ada
5
5
  import type { ConversationChannel } from '../conversationChannel'
6
6
  import type { QuickReply } from '../quickReplies/quickReply.types'
7
7
  import type { ConversationsTheme } from '../types'
8
+ import type { ParticipantAuthorAvatarRenderer } from './ParticipantAuthorAvatar'
8
9
  import { ParticipantConversationsScreen } from './ParticipantConversationsScreen'
9
10
  import type {
10
11
  ParticipantConversationsClassNames,
@@ -34,6 +35,12 @@ export type ParticipantConversationsProps = {
34
35
  renderSubjectIcon?: ParticipantSubjectIconRenderer
35
36
  renderSubjectCard?: (conversation: ParticipantConversationSummary) => ReactNode
36
37
  onOpenSubject?: (subject: ParticipantSubjectRef) => void
38
+ /** Absent draws no avatar. The SDK has no photo: the host supplies it through renderAuthorAvatar. */
39
+ avatars?: 'initials'
40
+ /** Host slot for the author's photo; wins over 'initials' and enables avatars on its own. */
41
+ renderAuthorAvatar?: ParticipantAuthorAvatarRenderer
42
+ /** Speech-bubble tail on the bottom corner; false removes it. Default true. */
43
+ tail?: boolean
37
44
  }
38
45
 
39
46
  export function ParticipantConversations(props: ParticipantConversationsProps) {
@@ -0,0 +1,87 @@
1
+ import { afterAll, beforeAll, describe, expect, it, mock } from 'bun:test'
2
+ import { renderToStaticMarkup } from 'react-dom/server'
3
+
4
+ import type { ParticipantConversationsApi } from './participantApi.types'
5
+ import { buildConversation, buildMessage } from './participantFixtures.test-helper'
6
+ import type { ParticipantConversationsScreenProps } from './ParticipantConversationsScreen'
7
+ import * as realThreadModule from './useParticipantThread'
8
+
9
+ const MESSAGES = [
10
+ buildMessage({ id: 'a', authorName: 'Ana Souza', createdAt: '2026-10-01T10:00:00.000Z' }),
11
+ buildMessage({ id: 'b', direction: 'inbound', createdAt: '2026-10-01T10:01:00.000Z' }),
12
+ ]
13
+ const realUseParticipantThread = realThreadModule.useParticipantThread
14
+ let isInjecting = false
15
+
16
+ const API: ParticipantConversationsApi = {
17
+ listConversations: async () => ({ data: [] }),
18
+ fetchMessages: async () => [],
19
+ sendMessage: async () => ({ outcome: 'queued' }),
20
+ markRead: async () => undefined,
21
+ resolveAttachmentUrl: async () => 'https://files.example/a',
22
+ }
23
+
24
+ let ParticipantConversationsScreen: typeof import('./ParticipantConversationsScreen').ParticipantConversationsScreen
25
+
26
+ beforeAll(async () => {
27
+ // Server rendering runs no effects, so the messages are injected over the real hook.
28
+ mock.module('./useParticipantThread', () => ({
29
+ ...realThreadModule,
30
+ useParticipantThread: (...args: Parameters<typeof realUseParticipantThread>) => {
31
+ const result = realUseParticipantThread(...args)
32
+ return isInjecting ? { ...result, messages: MESSAGES, status: 'ready' as const } : result
33
+ },
34
+ }))
35
+ ParticipantConversationsScreen = (await import('./ParticipantConversationsScreen')).ParticipantConversationsScreen
36
+ isInjecting = true
37
+ })
38
+
39
+ afterAll(() => {
40
+ isInjecting = false
41
+ })
42
+
43
+ function renderThread(overrides: Partial<ParticipantConversationsScreenProps> = {}): string {
44
+ return renderToStaticMarkup(
45
+ <ParticipantConversationsScreen
46
+ api={API}
47
+ subjectGroups={[{ subjectType: 'invoice', label: 'Invoices' }]}
48
+ selected={{ subjectType: 'invoice', subjectId: '1' }}
49
+ onSelect={() => undefined}
50
+ inbox={{
51
+ status: 'ready',
52
+ conversations: [buildConversation({ subjectId: '1', subjectLabel: 'Invoice 4521' })],
53
+ hasMore: false,
54
+ refresh: async () => undefined,
55
+ loadMore: async () => undefined,
56
+ markSubjectRead: () => undefined,
57
+ }}
58
+ {...overrides}
59
+ />,
60
+ )
61
+ }
62
+
63
+ describe('participant options reach the DOM through the screen', () => {
64
+ it('injects the messages (sanity)', () => {
65
+ expect(renderThread()).toContain('cv-p-bubble--mine')
66
+ })
67
+
68
+ it('draws the group eyebrow from subjectGroups', () => {
69
+ expect(renderThread()).toContain('<p class="cv-p-thread__eyebrow">Invoices</p>')
70
+ })
71
+
72
+ it('draws no avatar by default and the initials with avatars="initials"', () => {
73
+ expect(renderThread()).not.toContain('cv-p-avatar')
74
+ expect(renderThread({ avatars: 'initials' })).toContain('class="cv-p-avatar" aria-hidden="true">AS<')
75
+ })
76
+
77
+ it('draws the host slot through renderAuthorAvatar', () => {
78
+ const markup = renderThread({ renderAuthorAvatar: () => <i>PHOTO</i> })
79
+ expect(markup).toContain('<i>PHOTO</i>')
80
+ })
81
+
82
+ it('keeps the tail by default and removes it from every bubble with tail={false}', () => {
83
+ expect(renderThread()).not.toContain('cv-p-bubble--no-tail')
84
+ const markup = renderThread({ tail: false })
85
+ expect(markup.match(/cv-p-bubble--no-tail/g)?.length).toBe(2)
86
+ })
87
+ })
@@ -0,0 +1,48 @@
1
+ import { createContext, useCallback, useContext, useEffect, useReducer, useRef, type ReactNode } from 'react'
2
+
3
+ import {
4
+ INITIAL_COPY_ANNOUNCEMENT,
5
+ copyAnnouncementReducer,
6
+ resolveLiveText,
7
+ type CopyAnnouncementState,
8
+ } from './participantCopyAnnouncement'
9
+
10
+ const ANNOUNCEMENT_MS = 3000
11
+
12
+ const AnnounceContext = createContext<(text: string) => void>(() => undefined)
13
+ const AnnouncementContext = createContext<CopyAnnouncementState>(INITIAL_COPY_ANNOUNCEMENT)
14
+
15
+ export function useAnnounceCopy(): (text: string) => void {
16
+ return useContext(AnnounceContext)
17
+ }
18
+
19
+ /** Holds the text of the conversation's single polite region; the copy buttons announce through it. */
20
+ export function ParticipantCopyAnnouncer({ children }: { readonly children: ReactNode }) {
21
+ const [announcement, dispatch] = useReducer(copyAnnouncementReducer, INITIAL_COPY_ANNOUNCEMENT)
22
+ const announce = useCallback((text: string): void => dispatch({ type: 'announce', text }), [])
23
+
24
+ useEffect(() => {
25
+ if (announcement.text === '') return undefined
26
+ const timer = setTimeout(() => dispatch({ type: 'expired' }), ANNOUNCEMENT_MS)
27
+ return () => clearTimeout(timer)
28
+ }, [announcement.tick, announcement.text])
29
+
30
+ return (
31
+ <AnnounceContext.Provider value={announce}>
32
+ <AnnouncementContext.Provider value={announcement}>{children}</AnnouncementContext.Provider>
33
+ </AnnounceContext.Provider>
34
+ )
35
+ }
36
+
37
+ /** The one aria-live region of the thread: new-message notice, or the last copy announcement. */
38
+ export function ParticipantThreadLive({ notice }: { readonly notice: string }) {
39
+ const copy = useContext(AnnouncementContext)
40
+ const baselineNotice = useRef<string | undefined>(undefined)
41
+ const resolution = resolveLiveText({ copy, notice, baselineNotice: baselineNotice.current })
42
+ baselineNotice.current = resolution.baselineNotice
43
+ return (
44
+ <div className="cv-p-thread__live" role="status" aria-live="polite">
45
+ {resolution.text}
46
+ </div>
47
+ )
48
+ }
@@ -0,0 +1,51 @@
1
+ import { Check, Copy } from 'lucide-react'
2
+
3
+ import { useParticipantCopyFeedback } from './useParticipantCopyFeedback'
4
+ import type { CopyableKind } from './participantMessageFormat.types'
5
+ import type { ParticipantConversationsLabels } from './participantLabels'
6
+
7
+ type CopyLabels = Pick<ParticipantConversationsLabels, 'copyValue' | 'valueCopied'>
8
+
9
+ export type ParticipantCopyableTokenProps = {
10
+ readonly copyKind: CopyableKind
11
+ readonly value: string
12
+ readonly labels: CopyLabels
13
+ }
14
+
15
+ export function ParticipantCopyableToken({ copyKind, value, labels }: ParticipantCopyableTokenProps) {
16
+ const { isCopied, copy } = useParticipantCopyFeedback()
17
+ return (
18
+ <button
19
+ type="button"
20
+ className={`cv-p-copyable cv-p-copyable--${copyKind}`}
21
+ aria-label={`${labels.copyValue} ${value}`}
22
+ onClick={() => copy(value, labels.valueCopied)}
23
+ >
24
+ {value}
25
+ {isCopied ? (
26
+ <span className="cv-p-copyable__done" aria-hidden="true">
27
+ <Check size={12} aria-hidden={true} /> {labels.valueCopied}
28
+ </span>
29
+ ) : null}
30
+ </button>
31
+ )
32
+ }
33
+
34
+ export type ParticipantLinkCopyButtonProps = {
35
+ readonly value: string
36
+ readonly labels: CopyLabels
37
+ }
38
+
39
+ export function ParticipantLinkCopyButton({ value, labels }: ParticipantLinkCopyButtonProps) {
40
+ const { isCopied, copy } = useParticipantCopyFeedback()
41
+ return (
42
+ <button
43
+ type="button"
44
+ className="cv-p-text__link-copy"
45
+ aria-label={`${labels.copyValue} ${value}`}
46
+ onClick={() => copy(value, labels.valueCopied)}
47
+ >
48
+ {isCopied ? <Check size={14} aria-hidden={true} /> : <Copy size={14} aria-hidden={true} />}
49
+ </button>
50
+ )
51
+ }
@@ -0,0 +1,18 @@
1
+ import { toDateTimeAttribute } from './participantDay'
2
+
3
+ export type ParticipantDayDividerProps = {
4
+ readonly day: Date
5
+ readonly locale?: string
6
+ }
7
+
8
+ export function ParticipantDayDivider({ day, locale }: ParticipantDayDividerProps) {
9
+ const label = day.toLocaleDateString(locale, { weekday: 'short', day: '2-digit', month: '2-digit' })
10
+
11
+ return (
12
+ <div className="cv-p-day" role="separator" aria-label={label}>
13
+ <time className="cv-p-day__label" dateTime={toDateTimeAttribute(day)}>
14
+ {label}
15
+ </time>
16
+ </div>
17
+ )
18
+ }
@@ -12,6 +12,7 @@ import { resolveLoadView, type ParticipantLoadStatus } from './participantLoadVi
12
12
  import { ParticipantInboxRow } from './ParticipantInboxRow'
13
13
  import { ParticipantInboxSearchField, type ParticipantInboxSearch } from './ParticipantInboxSearchField'
14
14
  import { ParticipantLoadError, ParticipantLoading } from './ParticipantLoadState'
15
+ import { useRevealActiveChip } from './useRevealActiveChip'
15
16
 
16
17
  export type ParticipantInboxProps = {
17
18
  readonly view: ParticipantInboxView
@@ -54,8 +55,9 @@ type FiltersProps = {
54
55
  }
55
56
 
56
57
  function Filters({ filters, active, labels, onChange }: FiltersProps) {
58
+ const strip = useRevealActiveChip(active)
57
59
  return (
58
- <div className="cv-p-filters" role="group" aria-label={labels.filtersGroup}>
60
+ <div className="cv-p-filters" role="group" aria-label={labels.filtersGroup} ref={strip}>
59
61
  {filters.map((filter) => (
60
62
  <button
61
63
  key={filter.subjectType}
@@ -4,6 +4,7 @@ import { formatTimestamp, isSameDay } from '../lib/format'
4
4
  import type { ParticipantSubjectGroup, ParticipantSubjectIconRenderer } from './participant.types'
5
5
  import { formatParticipantLabel, type ParticipantConversationsLabels } from './participantLabels'
6
6
  import { ParticipantChannelBadges } from './ParticipantChannelBadges'
7
+ import { isDrawable, resolveConversationIcon } from './participantSubjectIcon'
7
8
 
8
9
  export type ParticipantInboxRowProps = {
9
10
  readonly conversation: ParticipantConversationSummary
@@ -21,17 +22,18 @@ function formatRowTime(iso: string, locale: string | undefined): string {
21
22
  }
22
23
 
23
24
  function RowIcon({ props, kind }: { readonly props: ParticipantInboxRowProps; readonly kind: string }) {
24
- const custom = props.renderSubjectIcon?.(props.conversation)
25
- if (custom !== null && custom !== undefined && custom !== false && custom !== '') {
25
+ const { conversation, group, renderSubjectIcon } = props
26
+ const hostIcon = renderSubjectIcon?.(conversation)
27
+ if (isDrawable(hostIcon)) {
26
28
  return (
27
29
  <span className="cv-p-row__icon cv-p-inbox__icon" aria-hidden="true">
28
- {custom}
30
+ {hostIcon}
29
31
  </span>
30
32
  )
31
33
  }
32
34
  return (
33
35
  <span className="cv-p-row__icon" aria-hidden="true">
34
- {props.group?.icon ?? kind.slice(0, 2)}
36
+ {resolveConversationIcon({ conversation, group }) ?? kind.slice(0, 2)}
35
37
  </span>
36
38
  )
37
39
  }
@@ -1,4 +1,5 @@
1
1
  import { describe, expect, it } from 'bun:test'
2
+ import type { ReactNode } from 'react'
2
3
  import { renderToStaticMarkup } from 'react-dom/server'
3
4
 
4
5
  import { DEFAULT_PARTICIPANT_CONVERSATIONS_LABELS } from './participantLabels'
@@ -9,7 +10,10 @@ import { ParticipantMessageBubble } from './ParticipantMessageBubble'
9
10
  const LABELS = DEFAULT_PARTICIPANT_CONVERSATIONS_LABELS
10
11
  const resolveAttachmentUrl = async (): Promise<string> => 'https://files.example/a.png'
11
12
 
12
- function renderServer(message = buildMessage({ id: 'm1' }), extra: { onRetry?: () => void; confirmsRead?: boolean } = {}): string {
13
+ function renderServer(
14
+ message = buildMessage({ id: 'm1' }),
15
+ extra: { onRetry?: () => void; confirmsRead?: boolean } = {},
16
+ ): string {
13
17
  const item: ParticipantTimelineItem = { kind: 'server', message }
14
18
  return renderToStaticMarkup(
15
19
  <ParticipantMessageBubble
@@ -82,14 +86,18 @@ describe('ParticipantMessageBubble', () => {
82
86
  })
83
87
 
84
88
  it('downgrades read to delivered when the channel does not confirm reads', () => {
85
- const markup = renderServer(buildMessage({ id: 'a', direction: 'inbound', status: 'read' }), { confirmsRead: false })
89
+ const markup = renderServer(buildMessage({ id: 'a', direction: 'inbound', status: 'read' }), {
90
+ confirmsRead: false,
91
+ })
86
92
 
87
93
  expect(markup).toContain('cv-status-ticks--delivered')
88
94
  expect(markup).not.toContain('cv-status-ticks--read')
89
95
  })
90
96
 
91
97
  it('shows no status on the other side', () => {
92
- expect(renderServer(buildMessage({ id: 'a', direction: 'outbound', status: 'read' }))).not.toContain('cv-status-ticks')
98
+ expect(renderServer(buildMessage({ id: 'a', direction: 'outbound', status: 'read' }))).not.toContain(
99
+ 'cv-status-ticks',
100
+ )
93
101
  })
94
102
 
95
103
  it('renders pending sending and queued with their texts', () => {
@@ -152,9 +160,9 @@ describe('ParticipantMessageBubble', () => {
152
160
  it('o texto do participante não é botão nem copia ao toque', () => {
153
161
  const markup = renderServer(buildMessage({ id: 'a', direction: 'inbound' }))
154
162
 
155
- expect(markup).toContain('class="cv-message-text"')
163
+ expect(markup).toContain('class="cv-p-text"')
156
164
  expect(markup).not.toContain('role="button"')
157
- expect(markup).not.toContain('cv-message-text--copyable')
165
+ expect(markup).not.toContain('cv-p-text--copyable')
158
166
  })
159
167
 
160
168
  it('uses only cv-p-* classes and no Tailwind utilities', () => {
@@ -165,3 +173,42 @@ describe('ParticipantMessageBubble', () => {
165
173
  expect(markup).toContain('cv-p-')
166
174
  })
167
175
  })
176
+
177
+ describe('ParticipantMessageBubble avatar slot', () => {
178
+ const GOLDEN_RECEIVED =
179
+ '<div class="cv-p-bubble"><span class="cv-p-bubble__author">Ana Souza</span><div class="cv-p-text">Hi</div><span class="cv-p-bubble__meta"><time class="cv-p-bubble__time" dateTime="2026-10-01T10:00:00.000Z">HH:MM</time></span></div>'
180
+
181
+ function renderWith(direction: 'inbound' | 'outbound', avatar?: ReactNode): string {
182
+ const message = buildMessage({ id: 'm', direction, authorName: 'Ana Souza', text: 'Hi' })
183
+ return renderToStaticMarkup(
184
+ <ParticipantMessageBubble
185
+ item={{ kind: 'server', message }}
186
+ labels={LABELS}
187
+ confirmsRead
188
+ resolveAttachmentUrl={resolveAttachmentUrl}
189
+ avatar={avatar}
190
+ />,
191
+ ).replace(/>\d\d:\d\d</, '>HH:MM<')
192
+ }
193
+
194
+ it('keeps the markup identical to the one before the prop existed when avatar is absent', () => {
195
+ expect(renderWith('outbound')).toBe(GOLDEN_RECEIVED)
196
+ })
197
+
198
+ it('puts the avatar to the left of a received bubble inside a row', () => {
199
+ const markup = renderWith('outbound', <span className="cv-p-avatar">AS</span>)
200
+ expect(markup).toBe(
201
+ `<div class="cv-p-bubble-row"><span class="cv-p-bubble-row__avatar"><span class="cv-p-avatar">AS</span></span>${GOLDEN_RECEIVED}</div>`,
202
+ )
203
+ })
204
+
205
+ it('reserves the avatar space when the slot is null', () => {
206
+ expect(renderWith('outbound', null)).toContain('<span class="cv-p-bubble-row__avatar"></span>')
207
+ })
208
+
209
+ it('never draws the avatar on an own message', () => {
210
+ const own = renderWith('inbound', <span className="cv-p-avatar">AS</span>)
211
+ expect(own).not.toContain('cv-p-avatar')
212
+ expect(own).not.toContain('cv-p-bubble-row')
213
+ })
214
+ })
@@ -1,14 +1,16 @@
1
+ import type { ReactNode } from 'react'
2
+
1
3
  import type { MessageDeliveryStatus, ParticipantAttachment } from '@adatechnology/conversation-contracts'
2
4
 
3
- import { MessageText } from '../MessageText'
4
- import { StatusTicks } from '../StatusTicks'
5
5
  import { formatTimestamp } from '../lib/format'
6
+ import { FailedActions, OwnStatus } from './ParticipantBubbleStatus'
7
+ import { ParticipantMessageText } from './ParticipantMessageText'
6
8
  import { ParticipantAttachmentItem, type ResolveParticipantAttachmentUrl } from './ParticipantAttachmentItem'
7
9
  import type { ParticipantConversationsLabels } from './participantLabels'
10
+ import type { ParticipantPerspective } from './participantPerspective'
8
11
  import {
9
12
  isOwnMessage,
10
13
  resolveOwnMessageStatus,
11
- type ParticipantOwnMessageStatus,
12
14
  type ParticipantPendingDisplayState,
13
15
  type ParticipantTimelineItem,
14
16
  } from './participantMessages'
@@ -24,6 +26,12 @@ export type ParticipantMessageBubbleProps = {
24
26
  readonly onDiscard?: () => void
25
27
  /** Absent means no edit button. */
26
28
  readonly onEdit?: () => void
29
+ /** Opt-in slot to the left of a received bubble; null reserves the space, absent changes nothing. */
30
+ readonly avatar?: ReactNode
31
+ /** Speech-bubble tail on the bottom corner; false removes it. Default true. */
32
+ readonly tail?: boolean
33
+ /** Who is looking; absent is the participant. The operator owns the outbound messages. */
34
+ readonly perspective?: ParticipantPerspective
27
35
  }
28
36
 
29
37
  type BubbleContent = {
@@ -37,11 +45,11 @@ type BubbleContent = {
37
45
  readonly serverStatus?: MessageDeliveryStatus
38
46
  }
39
47
 
40
- function describeItem(item: ParticipantTimelineItem): BubbleContent {
48
+ function describeItem(item: ParticipantTimelineItem, perspective?: ParticipantPerspective): BubbleContent {
41
49
  if (item.kind === 'server') {
42
50
  const { message } = item
43
51
  return {
44
- isMine: isOwnMessage(message),
52
+ isMine: isOwnMessage(message, perspective),
45
53
  authorName: message.authorName ?? undefined,
46
54
  text: message.text ?? undefined,
47
55
  createdAt: message.createdAt,
@@ -60,79 +68,11 @@ function describeItem(item: ParticipantTimelineItem): BubbleContent {
60
68
  }
61
69
  }
62
70
 
63
- function statusText(status: ParticipantOwnMessageStatus, labels: ParticipantConversationsLabels): string {
64
- const byStatus: Record<ParticipantOwnMessageStatus, string> = {
65
- sending: labels.statusSending,
66
- queued: labels.statusQueued,
67
- sent: labels.statusSent,
68
- delivered: labels.statusDelivered,
69
- read: labels.statusRead,
70
- failed: labels.statusFailed,
71
- }
72
- return byStatus[status]
73
- }
74
-
75
- type OwnStatusProps = {
76
- readonly status: ParticipantOwnMessageStatus
77
- readonly labels: ParticipantConversationsLabels
78
- readonly onRetry?: () => void
79
- }
80
-
81
- function OwnStatus({ status, labels, onRetry }: OwnStatusProps) {
82
- const ticksStatus = status === 'sending' ? 'queued' : status
83
- const isVisibleText = status === 'sending' || status === 'queued' || status === 'failed'
84
-
85
- if (status === 'failed' && onRetry) {
86
- return (
87
- <button type="button" className="cv-p-bubble__retry" onClick={onRetry}>
88
- <StatusTicks status="failed" appearance="stylesheet" />
89
- <span>{labels.statusFailed}</span>
90
- </button>
91
- )
92
- }
93
-
94
- const text = status === 'failed' ? labels.statusFailedShort : statusText(status, labels)
95
- return (
96
- <span className="cv-p-bubble__status">
97
- <StatusTicks status={ticksStatus} appearance="stylesheet" />
98
- <span className={isVisibleText ? 'cv-p-bubble__status-text' : 'cv-p-sr-only'}>{text}</span>
99
- </span>
100
- )
101
- }
102
-
103
- type FailedActionsProps = {
104
- readonly labels: ParticipantConversationsLabels
105
- readonly onDiscard?: () => void
106
- readonly onEdit?: () => void
107
- }
108
-
109
- function FailedActions({ labels, onDiscard, onEdit }: FailedActionsProps) {
110
- if (!onDiscard && !onEdit) return null
111
- return (
112
- <span className="cv-p-bubble__actions">
113
- {onEdit ? (
114
- <button type="button" className="cv-p-bubble__action" onClick={onEdit}>
115
- {labels.edit}
116
- </button>
117
- ) : null}
118
- {onDiscard ? (
119
- <button type="button" className="cv-p-bubble__action" onClick={onDiscard}>
120
- {labels.discard}
121
- </button>
122
- ) : null}
123
- </span>
124
- )
125
- }
126
-
127
- function toTextPayload(text: string, createdAt: string, isMine: boolean) {
128
- return {
129
- id: createdAt,
130
- type: 'text' as const,
131
- content: text,
132
- direction: isMine ? ('inbound' as const) : ('outbound' as const),
133
- sender: isMine ? ('customer' as const) : ('agent' as const),
134
- timestamp: createdAt,
135
- }
71
+ function bubbleClassName(isMine: boolean, hasTail: boolean): string {
72
+ const classes = ['cv-p-bubble']
73
+ if (isMine) classes.push('cv-p-bubble--mine')
74
+ if (!hasTail) classes.push('cv-p-bubble--no-tail')
75
+ return classes.join(' ')
136
76
  }
137
77
 
138
78
  export function ParticipantMessageBubble({
@@ -143,23 +83,24 @@ export function ParticipantMessageBubble({
143
83
  onRetry,
144
84
  onDiscard,
145
85
  onEdit,
86
+ avatar,
87
+ tail = true,
88
+ perspective,
146
89
  }: ParticipantMessageBubbleProps) {
147
- const content = describeItem(item)
90
+ const content = describeItem(item, perspective)
148
91
  const { isMine } = content
149
92
  const ownStatus = isMine
150
93
  ? resolveOwnMessageStatus({ displayState: content.displayState, serverStatus: content.serverStatus, confirmsRead })
151
94
  : undefined
152
95
 
153
- return (
154
- <div className={isMine ? 'cv-p-bubble cv-p-bubble--mine' : 'cv-p-bubble'}>
96
+ const bubble = (
97
+ <div className={bubbleClassName(isMine, tail)}>
155
98
  {isMine ? (
156
99
  <span className="cv-p-sr-only">{labels.me}</span>
157
100
  ) : content.authorName ? (
158
101
  <span className="cv-p-bubble__author">{content.authorName}</span>
159
102
  ) : null}
160
- {content.text ? (
161
- <MessageText message={toTextPayload(content.text, content.createdAt, isMine)} copyOnClick={false} appearance="stylesheet" />
162
- ) : null}
103
+ {content.text ? <ParticipantMessageText text={content.text} labels={labels} /> : null}
163
104
  {content.attachments.map((attachment) => (
164
105
  <ParticipantAttachmentItem
165
106
  key={attachment.id}
@@ -182,4 +123,11 @@ export function ParticipantMessageBubble({
182
123
  {ownStatus === 'failed' ? <FailedActions labels={labels} onDiscard={onDiscard} onEdit={onEdit} /> : null}
183
124
  </div>
184
125
  )
126
+ if (avatar === undefined || isMine) return bubble
127
+ return (
128
+ <div className="cv-p-bubble-row">
129
+ <span className="cv-p-bubble-row__avatar">{avatar}</span>
130
+ {bubble}
131
+ </div>
132
+ )
185
133
  }
@@ -0,0 +1,54 @@
1
+ import type { ReactNode } from 'react'
2
+
3
+ import { ParticipantCopyableToken, ParticipantLinkCopyButton } from './ParticipantCopyableToken'
4
+ import type { ParticipantConversationsLabels } from './participantLabels'
5
+ import type { InlineNode } from './participantMessageFormat.types'
6
+
7
+ export type ParticipantTextLabels = Pick<
8
+ ParticipantConversationsLabels,
9
+ 'externalLinkHint' | 'copyValue' | 'valueCopied'
10
+ >
11
+
12
+ const LINK_REL = 'noopener noreferrer nofollow ugc'
13
+
14
+ function renderLink(
15
+ node: Extract<InlineNode, { kind: 'link' }>,
16
+ labels: ParticipantTextLabels,
17
+ key: number,
18
+ ): ReactNode {
19
+ const isExternal = /^https?:/i.test(node.href)
20
+ return (
21
+ <span className="cv-p-text__link-group" key={key}>
22
+ <a className="cv-p-text__link" href={node.href} rel={LINK_REL} target={isExternal ? '_blank' : undefined}>
23
+ {node.label}
24
+ {isExternal ? <span className="cv-p-sr-only"> ({labels.externalLinkHint})</span> : null}
25
+ </a>
26
+ <ParticipantLinkCopyButton value={node.href} labels={labels} />
27
+ </span>
28
+ )
29
+ }
30
+
31
+ export function renderInlineNodes(nodes: readonly InlineNode[], labels: ParticipantTextLabels): ReactNode[] {
32
+ return nodes.map((node, key): ReactNode => {
33
+ switch (node.kind) {
34
+ case 'text':
35
+ return node.value
36
+ case 'strong':
37
+ return <strong key={key}>{renderInlineNodes(node.children, labels)}</strong>
38
+ case 'em':
39
+ return <em key={key}>{renderInlineNodes(node.children, labels)}</em>
40
+ case 'del':
41
+ return <del key={key}>{renderInlineNodes(node.children, labels)}</del>
42
+ case 'code':
43
+ return (
44
+ <code className="cv-p-text__code" key={key}>
45
+ {node.value}
46
+ </code>
47
+ )
48
+ case 'link':
49
+ return renderLink(node, labels, key)
50
+ case 'copyable':
51
+ return <ParticipantCopyableToken key={key} copyKind={node.copyKind} value={node.value} labels={labels} />
52
+ }
53
+ })
54
+ }