@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.
- package/README.md +126 -19
- package/dist/{chunk-HOKNKXIT.js → chunk-HMCOTZAX.js} +1 -1
- package/dist/{chunk-FAQLC3TX.js → chunk-L3OFAQPS.js} +2 -4
- package/dist/{chunk-CBYEHPX6.js → chunk-PJNR6UMN.js} +63 -1
- package/dist/flows/index.js +3 -5
- package/dist/index.js +834 -764
- package/dist/participant/index.d.ts +18 -1
- package/dist/participant/index.js +1188 -313
- package/dist/preview/index.js +2 -3
- package/dist/styles.css +583 -19
- package/package.json +3 -3
- package/src/participant/ParticipantAuthorAvatar.test.tsx +30 -0
- package/src/participant/ParticipantAuthorAvatar.tsx +24 -0
- package/src/participant/ParticipantBackButton.tsx +14 -0
- package/src/participant/ParticipantBubbleStatus.tsx +67 -0
- package/src/participant/ParticipantChannelBadges.tsx +13 -4
- package/src/participant/ParticipantComposer.tsx +8 -40
- package/src/participant/ParticipantComposerControls.tsx +83 -0
- package/src/participant/ParticipantConversationAvatar.tsx +13 -0
- package/src/participant/ParticipantConversations.tsx +7 -0
- package/src/participant/ParticipantConversationsOptions.test.tsx +87 -0
- package/src/participant/ParticipantCopyAnnouncer.tsx +48 -0
- package/src/participant/ParticipantCopyableToken.tsx +51 -0
- package/src/participant/ParticipantDayDivider.tsx +18 -0
- package/src/participant/ParticipantInbox.tsx +3 -1
- package/src/participant/ParticipantInboxRow.tsx +6 -4
- package/src/participant/ParticipantMessageBubble.test.tsx +52 -5
- package/src/participant/ParticipantMessageBubble.tsx +25 -81
- package/src/participant/ParticipantMessageNodes.tsx +54 -0
- package/src/participant/ParticipantMessageText.test.tsx +106 -0
- package/src/participant/ParticipantMessageText.tsx +52 -0
- package/src/participant/ParticipantProtocolBadge.tsx +15 -7
- package/src/participant/ParticipantProtocolView.test.tsx +4 -6
- package/src/participant/ParticipantScrollToLatest.test.tsx +153 -0
- package/src/participant/ParticipantScrollToLatest.tsx +40 -0
- package/src/participant/ParticipantThread.test.tsx +37 -0
- package/src/participant/ParticipantThread.tsx +45 -95
- package/src/participant/ParticipantThreadAvatarSequence.test.tsx +65 -0
- package/src/participant/ParticipantThreadHeader.test.tsx +181 -0
- package/src/participant/ParticipantThreadHeader.tsx +73 -0
- package/src/participant/ParticipantThreadScreen.tsx +24 -10
- package/src/participant/ParticipantTimeline.tsx +103 -0
- package/src/participant/index.ts +1 -0
- package/src/participant/participantAutoGrow.test.ts +43 -0
- package/src/participant/participantAutoGrow.ts +12 -0
- package/src/participant/participantAvatar.test.ts +74 -0
- package/src/participant/participantAvatar.ts +48 -0
- package/src/participant/participantBubbleTail.test.tsx +107 -0
- package/src/participant/participantChatDesign.test.tsx +198 -0
- package/src/participant/participantCopyAnnouncement.test.ts +52 -0
- package/src/participant/participantCopyAnnouncement.ts +37 -0
- package/src/participant/participantCss.test-helper.ts +34 -0
- package/src/participant/participantDay.test.ts +22 -0
- package/src/participant/participantDay.ts +8 -0
- package/src/participant/participantDocuments.ts +45 -0
- package/src/participant/participantHeaderA11y.test.tsx +85 -0
- package/src/participant/participantHeaderDesign.test.tsx +111 -0
- package/src/participant/participantLabels.ts +11 -0
- package/src/participant/participantMessageAtoms.ts +53 -0
- package/src/participant/participantMessageCopyables.test.ts +141 -0
- package/src/participant/participantMessageCopyables.ts +76 -0
- package/src/participant/participantMessageFormat.test-helper.ts +21 -0
- package/src/participant/participantMessageFormat.test.ts +177 -0
- package/src/participant/participantMessageFormat.ts +135 -0
- package/src/participant/participantMessageFormat.types.ts +22 -0
- package/src/participant/participantMessageInline.test.ts +82 -0
- package/src/participant/participantMessageInline.ts +99 -0
- package/src/participant/participantMessageLinks.test.ts +100 -0
- package/src/participant/participantMessageLinks.ts +54 -0
- package/src/participant/participantMessageTextStyle.test.ts +137 -0
- package/src/participant/participantScrollFocus.test.ts +22 -0
- package/src/participant/participantScrollFocus.ts +10 -0
- package/src/participant/participantScrollView.test.ts +74 -0
- package/src/participant/participantScrollView.ts +38 -0
- package/src/participant/participantScrollbars.test.ts +156 -0
- package/src/participant/participantSubjectIcon.test.tsx +27 -0
- package/src/participant/participantSubjectIcon.ts +26 -0
- package/src/participant/useParticipantCopyFeedback.ts +33 -0
- package/src/participant/useParticipantThreadScroll.ts +10 -2
- package/src/participant/useRevealActiveChip.ts +20 -0
- package/src/participant/useScrollAwayState.ts +24 -0
- package/src/participant/useStickToBottom.ts +16 -2
- package/src/styles.css +137 -21
- package/dist/chunk-MRVFFJEH.js +0 -64
- 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(
|
|
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' }), {
|
|
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(
|
|
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-
|
|
163
|
+
expect(markup).toContain('class="cv-p-text"')
|
|
156
164
|
expect(markup).not.toContain('role="button"')
|
|
157
|
-
expect(markup).not.toContain('cv-
|
|
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
|
|
64
|
-
const
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
|
|
154
|
-
<div className={isMine
|
|
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&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&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('<script>')
|
|
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
|
-
<
|
|
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
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
-
</
|
|
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
|
|
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
|
-
|
|
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('
|
|
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
|
+
})
|