@adatechnology/conversations-ui 0.6.1 → 0.7.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 (85) hide show
  1. package/README.md +126 -19
  2. package/dist/{chunk-HOKNKXIT.js → chunk-HMCOTZAX.js} +1 -1
  3. package/dist/{chunk-FAQLC3TX.js → chunk-L3OFAQPS.js} +2 -4
  4. package/dist/{chunk-CBYEHPX6.js → chunk-PJNR6UMN.js} +63 -1
  5. package/dist/flows/index.js +3 -5
  6. package/dist/index.js +834 -764
  7. package/dist/participant/index.d.ts +18 -1
  8. package/dist/participant/index.js +1188 -313
  9. package/dist/preview/index.js +2 -3
  10. package/dist/styles.css +583 -19
  11. package/package.json +3 -3
  12. package/src/participant/ParticipantAuthorAvatar.test.tsx +30 -0
  13. package/src/participant/ParticipantAuthorAvatar.tsx +24 -0
  14. package/src/participant/ParticipantBackButton.tsx +14 -0
  15. package/src/participant/ParticipantBubbleStatus.tsx +67 -0
  16. package/src/participant/ParticipantChannelBadges.tsx +13 -4
  17. package/src/participant/ParticipantComposer.tsx +8 -40
  18. package/src/participant/ParticipantComposerControls.tsx +83 -0
  19. package/src/participant/ParticipantConversationAvatar.tsx +13 -0
  20. package/src/participant/ParticipantConversations.tsx +7 -0
  21. package/src/participant/ParticipantConversationsOptions.test.tsx +87 -0
  22. package/src/participant/ParticipantCopyAnnouncer.tsx +48 -0
  23. package/src/participant/ParticipantCopyableToken.tsx +51 -0
  24. package/src/participant/ParticipantDayDivider.tsx +18 -0
  25. package/src/participant/ParticipantInbox.tsx +3 -1
  26. package/src/participant/ParticipantInboxRow.tsx +6 -4
  27. package/src/participant/ParticipantMessageBubble.test.tsx +52 -5
  28. package/src/participant/ParticipantMessageBubble.tsx +25 -81
  29. package/src/participant/ParticipantMessageNodes.tsx +54 -0
  30. package/src/participant/ParticipantMessageText.test.tsx +106 -0
  31. package/src/participant/ParticipantMessageText.tsx +52 -0
  32. package/src/participant/ParticipantProtocolBadge.tsx +15 -7
  33. package/src/participant/ParticipantProtocolView.test.tsx +4 -6
  34. package/src/participant/ParticipantScrollToLatest.test.tsx +153 -0
  35. package/src/participant/ParticipantScrollToLatest.tsx +40 -0
  36. package/src/participant/ParticipantThread.test.tsx +37 -0
  37. package/src/participant/ParticipantThread.tsx +45 -95
  38. package/src/participant/ParticipantThreadAvatarSequence.test.tsx +65 -0
  39. package/src/participant/ParticipantThreadHeader.test.tsx +181 -0
  40. package/src/participant/ParticipantThreadHeader.tsx +73 -0
  41. package/src/participant/ParticipantThreadScreen.tsx +24 -10
  42. package/src/participant/ParticipantTimeline.tsx +103 -0
  43. package/src/participant/index.ts +1 -0
  44. package/src/participant/participantAutoGrow.test.ts +43 -0
  45. package/src/participant/participantAutoGrow.ts +12 -0
  46. package/src/participant/participantAvatar.test.ts +74 -0
  47. package/src/participant/participantAvatar.ts +48 -0
  48. package/src/participant/participantBubbleTail.test.tsx +107 -0
  49. package/src/participant/participantChatDesign.test.tsx +198 -0
  50. package/src/participant/participantCopyAnnouncement.test.ts +52 -0
  51. package/src/participant/participantCopyAnnouncement.ts +37 -0
  52. package/src/participant/participantCss.test-helper.ts +34 -0
  53. package/src/participant/participantDay.test.ts +22 -0
  54. package/src/participant/participantDay.ts +8 -0
  55. package/src/participant/participantDocuments.ts +45 -0
  56. package/src/participant/participantHeaderA11y.test.tsx +85 -0
  57. package/src/participant/participantHeaderDesign.test.tsx +111 -0
  58. package/src/participant/participantLabels.ts +11 -0
  59. package/src/participant/participantMessageAtoms.ts +53 -0
  60. package/src/participant/participantMessageCopyables.test.ts +141 -0
  61. package/src/participant/participantMessageCopyables.ts +76 -0
  62. package/src/participant/participantMessageFormat.test-helper.ts +21 -0
  63. package/src/participant/participantMessageFormat.test.ts +177 -0
  64. package/src/participant/participantMessageFormat.ts +135 -0
  65. package/src/participant/participantMessageFormat.types.ts +22 -0
  66. package/src/participant/participantMessageInline.test.ts +82 -0
  67. package/src/participant/participantMessageInline.ts +99 -0
  68. package/src/participant/participantMessageLinks.test.ts +100 -0
  69. package/src/participant/participantMessageLinks.ts +54 -0
  70. package/src/participant/participantMessageTextStyle.test.ts +137 -0
  71. package/src/participant/participantScrollFocus.test.ts +22 -0
  72. package/src/participant/participantScrollFocus.ts +10 -0
  73. package/src/participant/participantScrollView.test.ts +74 -0
  74. package/src/participant/participantScrollView.ts +38 -0
  75. package/src/participant/participantScrollbars.test.ts +156 -0
  76. package/src/participant/participantSubjectIcon.test.tsx +27 -0
  77. package/src/participant/participantSubjectIcon.ts +26 -0
  78. package/src/participant/useParticipantCopyFeedback.ts +33 -0
  79. package/src/participant/useParticipantThreadScroll.ts +10 -2
  80. package/src/participant/useRevealActiveChip.ts +20 -0
  81. package/src/participant/useScrollAwayState.ts +24 -0
  82. package/src/participant/useStickToBottom.ts +16 -2
  83. package/src/styles.css +137 -21
  84. package/dist/chunk-MRVFFJEH.js +0 -64
  85. package/dist/chunk-OVFCLRQQ.js +0 -84
@@ -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,15 @@
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'
8
10
  import {
9
11
  isOwnMessage,
10
12
  resolveOwnMessageStatus,
11
- type ParticipantOwnMessageStatus,
12
13
  type ParticipantPendingDisplayState,
13
14
  type ParticipantTimelineItem,
14
15
  } from './participantMessages'
@@ -24,6 +25,10 @@ export type ParticipantMessageBubbleProps = {
24
25
  readonly onDiscard?: () => void
25
26
  /** Absent means no edit button. */
26
27
  readonly onEdit?: () => void
28
+ /** Opt-in slot to the left of a received bubble; null reserves the space, absent changes nothing. */
29
+ readonly avatar?: ReactNode
30
+ /** Speech-bubble tail on the bottom corner; false removes it. Default true. */
31
+ readonly tail?: boolean
27
32
  }
28
33
 
29
34
  type BubbleContent = {
@@ -60,79 +65,11 @@ function describeItem(item: ParticipantTimelineItem): BubbleContent {
60
65
  }
61
66
  }
62
67
 
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
- }
68
+ function bubbleClassName(isMine: boolean, hasTail: boolean): string {
69
+ const classes = ['cv-p-bubble']
70
+ if (isMine) classes.push('cv-p-bubble--mine')
71
+ if (!hasTail) classes.push('cv-p-bubble--no-tail')
72
+ return classes.join(' ')
136
73
  }
137
74
 
138
75
  export function ParticipantMessageBubble({
@@ -143,6 +80,8 @@ export function ParticipantMessageBubble({
143
80
  onRetry,
144
81
  onDiscard,
145
82
  onEdit,
83
+ avatar,
84
+ tail = true,
146
85
  }: ParticipantMessageBubbleProps) {
147
86
  const content = describeItem(item)
148
87
  const { isMine } = content
@@ -150,16 +89,14 @@ export function ParticipantMessageBubble({
150
89
  ? resolveOwnMessageStatus({ displayState: content.displayState, serverStatus: content.serverStatus, confirmsRead })
151
90
  : undefined
152
91
 
153
- return (
154
- <div className={isMine ? 'cv-p-bubble cv-p-bubble--mine' : 'cv-p-bubble'}>
92
+ const bubble = (
93
+ <div className={bubbleClassName(isMine, tail)}>
155
94
  {isMine ? (
156
95
  <span className="cv-p-sr-only">{labels.me}</span>
157
96
  ) : content.authorName ? (
158
97
  <span className="cv-p-bubble__author">{content.authorName}</span>
159
98
  ) : null}
160
- {content.text ? (
161
- <MessageText message={toTextPayload(content.text, content.createdAt, isMine)} copyOnClick={false} appearance="stylesheet" />
162
- ) : null}
99
+ {content.text ? <ParticipantMessageText text={content.text} labels={labels} /> : null}
163
100
  {content.attachments.map((attachment) => (
164
101
  <ParticipantAttachmentItem
165
102
  key={attachment.id}
@@ -182,4 +119,11 @@ export function ParticipantMessageBubble({
182
119
  {ownStatus === 'failed' ? <FailedActions labels={labels} onDiscard={onDiscard} onEdit={onEdit} /> : null}
183
120
  </div>
184
121
  )
122
+ if (avatar === undefined || isMine) return bubble
123
+ return (
124
+ <div className="cv-p-bubble-row">
125
+ <span className="cv-p-bubble-row__avatar">{avatar}</span>
126
+ {bubble}
127
+ </div>
128
+ )
185
129
  }
@@ -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
+ }
@@ -0,0 +1,106 @@
1
+ import { describe, expect, it } from 'bun:test'
2
+ import { renderToStaticMarkup } from 'react-dom/server'
3
+
4
+ import { ParticipantMessageText } from './ParticipantMessageText'
5
+ import { DEFAULT_PARTICIPANT_CONVERSATIONS_LABELS } from './participantLabels'
6
+
7
+ const LABELS = DEFAULT_PARTICIPANT_CONVERSATIONS_LABELS
8
+
9
+ function render(text: string | null | undefined, labels = LABELS): string {
10
+ return renderToStaticMarkup(<ParticipantMessageText text={text} labels={labels} />)
11
+ }
12
+
13
+ describe('ParticipantMessageText - markup', () => {
14
+ it('keeps plain text as minimal, stable markup', () => {
15
+ expect(render('Olá, tudo bem?')).toBe('<div class="cv-p-text">Olá, tudo bem?</div>')
16
+ expect(render('a\nb')).toBe('<div class="cv-p-text">a\nb</div>')
17
+ })
18
+
19
+ it('draws nothing for empty input', () => {
20
+ expect(render('')).toBe('')
21
+ expect(render(' ')).toBe('')
22
+ expect(render(null)).toBe('')
23
+ })
24
+
25
+ it('renders marks as semantic elements, nested', () => {
26
+ expect(render('*_a_* ~b~ `c`')).toBe(
27
+ '<div class="cv-p-text"><strong><em>a</em></strong> <del>b</del> <code class="cv-p-text__code">c</code></div>',
28
+ )
29
+ })
30
+
31
+ it('renders blocks as pre, blockquote and lists', () => {
32
+ expect(render('```\nx\n```')).toBe('<div class="cv-p-text"><pre class="cv-p-text__pre"><code>x</code></pre></div>')
33
+ expect(render('> q')).toContain('<blockquote class="cv-p-text__quote">q</blockquote>')
34
+ expect(render('- a\n- b')).toContain('<ul class="cv-p-text__list"><li>a</li><li>b</li></ul>')
35
+ expect(render('3. a')).toContain('<ol class="cv-p-text__list" start="3"><li>a</li></ol>')
36
+ expect(render('1. a')).toContain('<ol class="cv-p-text__list"><li>a</li></ol>')
37
+ })
38
+ })
39
+
40
+ describe('ParticipantMessageText - links', () => {
41
+ it('opens http links safely in a new tab, announces it, and offers a separate copy button', () => {
42
+ const markup = render('veja https://a.com/?a=1&b=2 já')
43
+ expect(markup).toContain('<a class="cv-p-text__link" href="https://a.com/?a=1&amp;b=2"')
44
+ expect(markup).toContain('rel="noopener noreferrer nofollow ugc"')
45
+ expect(markup).toContain('target="_blank"')
46
+ expect(markup).toContain('<span class="cv-p-sr-only"> (opens in a new tab)</span>')
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">',
49
+ )
50
+ })
51
+
52
+ it('never makes the whole link a copy button', () => {
53
+ const markup = render('https://a.com')
54
+ expect(markup).toMatch(/<a [^>]*>https:\/\/a\.com<span[^>]*>[^<]*<\/span><\/a>/)
55
+ expect(markup).not.toMatch(/<button[^>]*><a /)
56
+ expect(markup.slice(markup.indexOf('<a '), markup.indexOf('</a>'))).not.toContain('<button')
57
+ expect(markup.indexOf('</a>')).toBeLessThan(markup.indexOf('<button'))
58
+ })
59
+
60
+ it('uses the host label for the new-tab hint', () => {
61
+ expect(render('https://a.com', { ...LABELS, externalLinkHint: 'abre em nova aba' })).toContain('(abre em nova aba)')
62
+ })
63
+
64
+ it('never links unsafe protocols', () => {
65
+ for (const text of ['javascript:alert(1)', 'data:text/html,<b>x</b>', 'vbscript:x', 'JAVASCRIPT:alert(1)']) {
66
+ expect(render(text)).not.toContain('<a ')
67
+ expect(render(text)).not.toContain('href=')
68
+ }
69
+ })
70
+
71
+ it('escapes hostile text: no script element and no event-handler attribute', () => {
72
+ const markup = render('<script>alert(1)</script> <img src=x onerror=alert(1)> *<b onclick=x>*')
73
+ expect(markup).not.toContain('<script')
74
+ expect(markup).not.toContain('<img')
75
+ expect(markup).not.toMatch(/<[a-z]+[^>]*\son[a-z]+=/i)
76
+ expect(markup).toContain('&lt;script&gt;')
77
+ })
78
+ })
79
+
80
+ describe('ParticipantMessageText - copyable tokens', () => {
81
+ it('renders a token as an accessible button with the value as typed', () => {
82
+ const markup = render('CPF 529.982.247-25')
83
+ expect(markup).toContain(
84
+ '<button type="button" class="cv-p-copyable cv-p-copyable--cpf" aria-label="Copy 529.982.247-25">529.982.247-25</button>',
85
+ )
86
+ })
87
+
88
+ it('renders one button per kind and labels it from the host labels', () => {
89
+ const markup = render('a@b.co (16) 99999-0000 11.222.333/0001-81', { ...LABELS, copyValue: 'Copiar' })
90
+ expect(markup).toContain('cv-p-copyable--email')
91
+ expect(markup).toContain('cv-p-copyable--phone')
92
+ expect(markup).toContain('cv-p-copyable--cnpj')
93
+ expect(markup).toContain('aria-label="Copiar a@b.co"')
94
+ })
95
+
96
+ it('does not highlight code, money, dates or short numbers', () => {
97
+ expect(render('`529.982.247-25`')).not.toContain('cv-p-copyable')
98
+ expect(render('R$ 1.234,56 em 10/10/2026, NF 4521 (12345)')).not.toContain('cv-p-copyable')
99
+ })
100
+
101
+ it('uses no live region of its own and no innerHTML', () => {
102
+ const markup = render('a@b.co https://a.com')
103
+ expect(markup).not.toContain('aria-live')
104
+ expect(markup).not.toContain('dangerouslySetInnerHTML')
105
+ })
106
+ })
@@ -0,0 +1,52 @@
1
+ import { useMemo } from 'react'
2
+
3
+ import { renderInlineNodes, type ParticipantTextLabels } from './ParticipantMessageNodes'
4
+ import { parseParticipantMessage } from './participantMessageFormat'
5
+ import type { FormatBlock } from './participantMessageFormat.types'
6
+
7
+ export type ParticipantMessageTextProps = {
8
+ readonly text: string | null | undefined
9
+ readonly labels: ParticipantTextLabels
10
+ }
11
+
12
+ function renderBlock(block: FormatBlock, labels: ParticipantTextLabels, key: number) {
13
+ if (block.kind === 'paragraph') return <span key={key}>{renderInlineNodes(block.children, labels)}</span>
14
+ if (block.kind === 'pre') {
15
+ return (
16
+ <pre className="cv-p-text__pre" key={key}>
17
+ <code>{block.value}</code>
18
+ </pre>
19
+ )
20
+ }
21
+ if (block.kind === 'quote') {
22
+ return (
23
+ <blockquote className="cv-p-text__quote" key={key}>
24
+ {renderInlineNodes(block.children, labels)}
25
+ </blockquote>
26
+ )
27
+ }
28
+ const items = block.items.map((item, index) => <li key={index}>{renderInlineNodes(item, labels)}</li>)
29
+ if (block.ordered) {
30
+ return (
31
+ <ol className="cv-p-text__list" key={key} start={block.start === 1 ? undefined : block.start}>
32
+ {items}
33
+ </ol>
34
+ )
35
+ }
36
+ return (
37
+ <ul className="cv-p-text__list" key={key}>
38
+ {items}
39
+ </ul>
40
+ )
41
+ }
42
+
43
+ export function ParticipantMessageText({ text, labels }: ParticipantMessageTextProps) {
44
+ const blocks = useMemo(() => parseParticipantMessage(text), [text])
45
+ if (blocks.length === 0) return null
46
+ const [only] = blocks
47
+ const children =
48
+ blocks.length === 1 && only?.kind === 'paragraph'
49
+ ? renderInlineNodes(only.children, labels)
50
+ : blocks.map((block, key) => renderBlock(block, labels, key))
51
+ return <div className="cv-p-text">{children}</div>
52
+ }
@@ -1,3 +1,4 @@
1
+ import { Check, Copy } from 'lucide-react'
1
2
  import { useEffect, useReducer } from 'react'
2
3
 
3
4
  import type { ParticipantConversationsLabels } from './participantLabels'
@@ -29,18 +30,25 @@ export function ParticipantProtocolBadgeView({
29
30
  const { isCopied, copyCount } = copyState
30
31
  return (
31
32
  <>
32
- <div className="cv-p-protocol cv-p-protocol--header">
33
+ <span className="cv-p-protocol cv-p-protocol--header">
33
34
  <span className="cv-p-protocol__code">
34
35
  <span className="cv-p-sr-only">{labels.protocolPrefix} </span>
35
36
  {protocol}
36
37
  </span>
37
- <button type="button" className="cv-p-protocol__copy" onClick={onCopy}>
38
- <span aria-hidden="true">{isCopied ? labels.protocolCopied : labels.copyProtocol}</span>
39
- <span className="cv-p-sr-only">
40
- {labels.copyProtocol} {protocol}
41
- </span>
38
+ <button
39
+ type="button"
40
+ className="cv-p-protocol__copy"
41
+ aria-label={`${labels.copyProtocol} ${protocol}`}
42
+ onClick={onCopy}
43
+ >
44
+ {isCopied ? <Check size={18} aria-hidden={true} /> : <Copy size={18} aria-hidden={true} />}
45
+ {isCopied ? (
46
+ <span className="cv-p-protocol__copied" aria-hidden="true">
47
+ {labels.protocolCopied}
48
+ </span>
49
+ ) : null}
42
50
  </button>
43
- </div>
51
+ </span>
44
52
  <div className="cv-p-sr-only" role="status" aria-live="polite">
45
53
  {isCopied ? <span key={copyCount}>{labels.protocolCopied}</span> : null}
46
54
  </div>
@@ -148,16 +148,14 @@ describe('copy button', () => {
148
148
  />,
149
149
  )
150
150
 
151
- it('has a default visible label starting the accessible name', () => {
151
+ it('has a default accessible name that starts with the visible action and carries the code', () => {
152
152
  expect(LABELS.copyProtocol).toBe('Copy protocol')
153
- const markup = render(false)
154
- expect(markup).toContain('<span aria-hidden="true">Copy protocol</span>')
155
- expect(markup).toContain('<span class="cv-p-sr-only">Copy protocol 261009-K7M2</span>')
153
+ expect(render(false)).toContain('aria-label="Copy protocol 261009-K7M2"')
156
154
  })
157
155
 
158
- it('swaps the visible text for the confirmation while copied, hidden from assistive tech', () => {
156
+ it('shows the confirmation next to the icon while copied, hidden from assistive tech', () => {
159
157
  const markup = render(true)
160
- expect(markup).toContain(`<span aria-hidden="true">${LABELS.protocolCopied}</span>`)
158
+ expect(markup).toContain(`<span class="cv-p-protocol__copied" aria-hidden="true">${LABELS.protocolCopied}</span>`)
161
159
  expect(markup).toContain(`<span>${LABELS.protocolCopied}</span></div>`)
162
160
  })
163
161
 
@@ -0,0 +1,153 @@
1
+ import { describe, expect, it } from 'bun:test'
2
+ import type { ReactElement } from 'react'
3
+ import { renderToStaticMarkup } from 'react-dom/server'
4
+
5
+ import {
6
+ buildConversation,
7
+ buildMessage,
8
+ findForeignClassTokens,
9
+ findUtilityClassTokens,
10
+ } from './participantFixtures.test-helper'
11
+ import { DEFAULT_PARTICIPANT_CONVERSATIONS_LABELS } from './participantLabels'
12
+ import { ParticipantScrollToLatest } from './ParticipantScrollToLatest'
13
+ import { ParticipantThread, type ParticipantThreadProps } from './ParticipantThread'
14
+
15
+ const LABEL = DEFAULT_PARTICIPANT_CONVERSATIONS_LABELS.scrollToLatest
16
+
17
+ function renderButton(overrides: Partial<Parameters<typeof ParticipantScrollToLatest>[0]> = {}): string {
18
+ return renderToStaticMarkup(
19
+ <ParticipantScrollToLatest
20
+ label={LABEL}
21
+ isAwayFromBottom
22
+ hasMessages
23
+ newMessagesCount={0}
24
+ onClick={() => undefined}
25
+ {...overrides}
26
+ />,
27
+ )
28
+ }
29
+
30
+ function renderThread(overrides: Partial<ParticipantThreadProps> = {}): string {
31
+ return renderToStaticMarkup(
32
+ <ParticipantThread
33
+ conversation={buildConversation({ subjectId: '1' })}
34
+ items={[{ kind: 'server', message: buildMessage({ id: 'a', createdAt: '2026-10-01T10:00:00.000Z' }) }]}
35
+ hasMore={false}
36
+ labels={DEFAULT_PARTICIPANT_CONVERSATIONS_LABELS}
37
+ resolveAttachmentUrl={async () => 'https://files.example/a'}
38
+ draft={{ value: '', onChange: () => undefined, files: [], onFilesChange: () => undefined }}
39
+ onSend={() => undefined}
40
+ status="ready"
41
+ refresh={() => undefined}
42
+ scroll={{
43
+ ref: { current: null },
44
+ onScroll: () => undefined,
45
+ isAwayFromBottom: true,
46
+ scrollToLatest: () => undefined,
47
+ }}
48
+ newMessagesCount={0}
49
+ isSending={false}
50
+ {...overrides}
51
+ />,
52
+ )
53
+ }
54
+
55
+ describe('ParticipantScrollToLatest', () => {
56
+ it('has a generic English default label', () => {
57
+ expect(LABEL).toBe('Scroll to latest message')
58
+ })
59
+
60
+ it('draws nothing near the end of the conversation', () => {
61
+ expect(renderButton({ isAwayFromBottom: false })).toBe('')
62
+ })
63
+
64
+ it('draws nothing without messages', () => {
65
+ expect(renderButton({ hasMessages: false })).toBe('')
66
+ })
67
+
68
+ it('is a named button that does not submit anything', () => {
69
+ const markup = renderButton()
70
+ expect(markup).toContain(`aria-label="${LABEL}"`)
71
+ expect(markup).toContain('type="button"')
72
+ expect(markup).toContain('class="cv-p-thread__latest-button"')
73
+ })
74
+
75
+ it('keeps the icon decorative', () => {
76
+ expect(renderButton()).toContain('aria-hidden="true"')
77
+ })
78
+
79
+ it('shows the count of new incoming messages in a badge hidden from the accessibility tree', () => {
80
+ expect(renderButton({ newMessagesCount: 3 })).toContain(
81
+ '<span class="cv-p-thread__latest-badge" aria-hidden="true">3</span>',
82
+ )
83
+ })
84
+
85
+ it('has no badge with zero new messages', () => {
86
+ expect(renderButton({ newMessagesCount: 0 })).not.toContain('cv-p-thread__latest-badge')
87
+ })
88
+
89
+ it('has the aria-label exactly as the label, without the count', () => {
90
+ expect(renderButton({ newMessagesCount: 3 })).toContain(`aria-label="${LABEL}"`)
91
+ })
92
+ })
93
+
94
+ describe('ParticipantScrollToLatest focus', () => {
95
+ it('moves focus to the scroller before scrolling, since the button unmounts on click', () => {
96
+ const order: string[] = []
97
+ const root = ParticipantScrollToLatest({
98
+ label: LABEL,
99
+ isAwayFromBottom: true,
100
+ hasMessages: true,
101
+ newMessagesCount: 0,
102
+ onClick: () => void order.push('scroll'),
103
+ }) as ReactElement<{ children: ReactElement<{ onClick: (event: unknown) => void }> }>
104
+ const button = root.props.children
105
+ button.props.onClick({
106
+ currentTarget: { closest: () => ({ focus: () => void order.push('focus') }) },
107
+ })
108
+ expect(order).toEqual(['focus', 'scroll'])
109
+ })
110
+
111
+ it('makes the scroller focusable by script only', () => {
112
+ expect(renderThread()).toMatch(/<div class="cv-p-thread__scroll" tabindex="-1">/)
113
+ })
114
+ })
115
+
116
+ describe('ParticipantThread with the scroll-to-latest button', () => {
117
+ it('draws the button inside the scroller, after the messages', () => {
118
+ const markup = renderThread()
119
+ const scroller = markup.indexOf('cv-p-thread__scroll')
120
+ expect(markup.indexOf('cv-p-thread__latest-button')).toBeGreaterThan(markup.indexOf('cv-p-thread__messages'))
121
+ expect(markup.indexOf('cv-p-thread__latest-button')).toBeGreaterThan(scroller)
122
+ expect(markup.indexOf('cv-p-thread__latest-button')).toBeLessThan(markup.indexOf('cv-p-thread__live'))
123
+ })
124
+
125
+ it('does not draw it while the reader is at the end', () => {
126
+ const scroll = { ref: { current: null }, onScroll: () => undefined, isAwayFromBottom: false }
127
+ expect(renderThread({ scroll })).not.toContain('cv-p-thread__latest')
128
+ })
129
+
130
+ it('does not draw it for a host that gives no away state', () => {
131
+ expect(renderThread({ scroll: { ref: { current: null }, onScroll: () => undefined } })).not.toContain(
132
+ 'cv-p-thread__latest',
133
+ )
134
+ })
135
+
136
+ it('announces only the new-messages label, never the count or the button label', () => {
137
+ const markup = renderThread({ newMessagesCount: 2 })
138
+ expect(markup).toContain('role="status" aria-live="polite">New messages</div>')
139
+ expect(markup.split('New messages').length - 1).toBe(1)
140
+ expect(markup.match(/aria-live/g)).toHaveLength(1)
141
+ })
142
+
143
+ it('lets the host translate the label', () => {
144
+ const labels = { ...DEFAULT_PARTICIPANT_CONVERSATIONS_LABELS, scrollToLatest: 'Ir para a última mensagem' }
145
+ expect(renderThread({ labels })).toContain('aria-label="Ir para a última mensagem"')
146
+ })
147
+
148
+ it('stays inside the participant namespace with no utility classes', () => {
149
+ const markup = renderThread({ newMessagesCount: 2 })
150
+ expect(findUtilityClassTokens(markup)).toEqual([])
151
+ expect(findForeignClassTokens(markup)).toEqual([])
152
+ })
153
+ })