@adatechnology/conversations-ui 0.6.0 → 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 +591 -19
- package/package.json +1 -1
- package/src/cssBlocks.test-helper.ts +31 -0
- package/src/legacyStyles.golden.test.ts +2 -31
- 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/participantLayoutShrink.test.ts +82 -0
- package/src/styles.css +140 -21
- package/dist/chunk-MRVFFJEH.js +0 -64
- package/dist/chunk-OVFCLRQQ.js +0 -84
|
@@ -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
|
|
25
|
-
|
|
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
|
-
{
|
|
30
|
+
{hostIcon}
|
|
29
31
|
</span>
|
|
30
32
|
)
|
|
31
33
|
}
|
|
32
34
|
return (
|
|
33
35
|
<span className="cv-p-row__icon" aria-hidden="true">
|
|
34
|
-
{
|
|
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(
|
|
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
|
|