@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,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
|
+
})
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { ChevronDown } from 'lucide-react'
|
|
2
|
+
|
|
3
|
+
import { focusScrollerOf } from './participantScrollFocus'
|
|
4
|
+
import { resolveScrollToLatestView } from './participantScrollView'
|
|
5
|
+
|
|
6
|
+
export type ParticipantScrollToLatestProps = {
|
|
7
|
+
readonly label: string
|
|
8
|
+
readonly isAwayFromBottom: boolean
|
|
9
|
+
readonly hasMessages: boolean
|
|
10
|
+
readonly newMessagesCount: number
|
|
11
|
+
/** Absent (a host that does not own the scroller) draws nothing. */
|
|
12
|
+
readonly onClick: (() => void) | undefined
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function ParticipantScrollToLatest({
|
|
16
|
+
label,
|
|
17
|
+
isAwayFromBottom,
|
|
18
|
+
hasMessages,
|
|
19
|
+
newMessagesCount,
|
|
20
|
+
onClick,
|
|
21
|
+
}: ParticipantScrollToLatestProps) {
|
|
22
|
+
const { isVisible, badge } = resolveScrollToLatestView({ isAwayFromBottom, newMessagesCount, hasMessages })
|
|
23
|
+
if (!isVisible || onClick === undefined) return null
|
|
24
|
+
return (
|
|
25
|
+
<div className="cv-p-thread__latest">
|
|
26
|
+
<button type="button" className="cv-p-thread__latest-button" aria-label={label} onClick={(event) => {
|
|
27
|
+
focusScrollerOf(event.currentTarget)
|
|
28
|
+
onClick()
|
|
29
|
+
}}
|
|
30
|
+
>
|
|
31
|
+
<ChevronDown size={24} aria-hidden="true" focusable="false" />
|
|
32
|
+
{badge ? (
|
|
33
|
+
<span className="cv-p-thread__latest-badge" aria-hidden="true">
|
|
34
|
+
{badge}
|
|
35
|
+
</span>
|
|
36
|
+
) : null}
|
|
37
|
+
</button>
|
|
38
|
+
</div>
|
|
39
|
+
)
|
|
40
|
+
}
|
|
@@ -146,4 +146,41 @@ describe('ParticipantThread', () => {
|
|
|
146
146
|
expect(findUtilityClassTokens(markup)).toEqual([])
|
|
147
147
|
expect(findForeignClassTokens(markup)).toEqual([])
|
|
148
148
|
})
|
|
149
|
+
|
|
150
|
+
it('draws no avatar by default', () => {
|
|
151
|
+
expect(render()).not.toContain('cv-p-avatar')
|
|
152
|
+
expect(render()).not.toContain('cv-p-bubble-row')
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
it('draws initials on the first received message of each sequence, never on own messages', () => {
|
|
156
|
+
const markup = render({
|
|
157
|
+
avatars: 'initials',
|
|
158
|
+
items: [
|
|
159
|
+
{ kind: 'server', message: buildMessage({ id: 'a', authorName: 'Ana Souza' }) },
|
|
160
|
+
{ kind: 'server', message: buildMessage({ id: 'b', authorName: 'Ana Souza' }) },
|
|
161
|
+
{ kind: 'server', message: buildMessage({ id: 'c', direction: 'inbound' }) },
|
|
162
|
+
{ kind: 'server', message: buildMessage({ id: 'd', authorName: 'Ana Souza' }) },
|
|
163
|
+
{ kind: 'server', message: buildMessage({ id: 'e', authorName: 'Bruno Lima' }) },
|
|
164
|
+
],
|
|
165
|
+
})
|
|
166
|
+
expect(markup.match(/class="cv-p-bubble-row"/g)?.length).toBe(4)
|
|
167
|
+
expect(markup.match(/class="cv-p-avatar" aria-hidden="true">AS</g)?.length).toBe(2)
|
|
168
|
+
expect(markup.match(/class="cv-p-avatar" aria-hidden="true">BL</g)?.length).toBe(1)
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
it('uses the neutral icon when the author has no usable name', () => {
|
|
172
|
+
const markup = render({ avatars: 'initials' })
|
|
173
|
+
expect(markup).toContain('class="cv-p-avatar" aria-hidden="true"><svg')
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
it('gives the host slot precedence over initials, even without the avatars prop', () => {
|
|
177
|
+
const markup = render({
|
|
178
|
+
avatars: 'initials',
|
|
179
|
+
renderAuthorAvatar: ({ name }) => <img alt="" src={`https://photos.example/${name ?? 'none'}.png`} />,
|
|
180
|
+
items: [{ kind: 'server', message: buildMessage({ id: 'a', authorName: 'Ana Souza' }) }],
|
|
181
|
+
})
|
|
182
|
+
expect(markup).toContain('src="https://photos.example/Ana Souza.png"')
|
|
183
|
+
expect(markup).not.toContain('>AS<')
|
|
184
|
+
expect(render({ renderAuthorAvatar: () => <i>x</i> })).toContain('<i>x</i>')
|
|
185
|
+
})
|
|
149
186
|
})
|
|
@@ -2,25 +2,22 @@ import type { ReactNode } from 'react'
|
|
|
2
2
|
|
|
3
3
|
import type { ParticipantConversationSummary, ParticipantSubjectRef } from '@adatechnology/conversation-contracts'
|
|
4
4
|
|
|
5
|
-
import { channelCapabilityFor } from '../channelCapability'
|
|
6
5
|
import type { ConversationChannel } from '../conversationChannel'
|
|
7
|
-
import { isSameDay } from '../lib/format'
|
|
8
6
|
import type { QuickReply } from '../quickReplies/quickReply.types'
|
|
7
|
+
import type { ParticipantTimelineItem } from './participantMessages'
|
|
9
8
|
import { ParticipantComposer } from './ParticipantComposer'
|
|
9
|
+
import { ParticipantCopyAnnouncer, ParticipantThreadLive } from './ParticipantCopyAnnouncer'
|
|
10
10
|
import type { ResolveParticipantAttachmentUrl } from './ParticipantAttachmentItem'
|
|
11
|
-
import {
|
|
12
|
-
bindPendingActions,
|
|
13
|
-
resolveParticipantBubbleActions,
|
|
14
|
-
type ParticipantPendingActions,
|
|
15
|
-
} from './participantBubbleActions'
|
|
11
|
+
import type { ParticipantPendingActions } from './participantBubbleActions'
|
|
16
12
|
import type { ParticipantConversationsLabels } from './participantLabels'
|
|
17
13
|
import { resolveLoadView, type ParticipantLoadStatus } from './participantLoadView'
|
|
18
14
|
import { ParticipantLoadError, ParticipantLoading } from './ParticipantLoadState'
|
|
19
15
|
import type { ParticipantThreadScroll } from './useStickToBottom'
|
|
20
|
-
import type {
|
|
21
|
-
import {
|
|
22
|
-
import {
|
|
23
|
-
import {
|
|
16
|
+
import type { ParticipantAuthorAvatarRenderer } from './ParticipantAuthorAvatar'
|
|
17
|
+
import { ParticipantScrollToLatest } from './ParticipantScrollToLatest'
|
|
18
|
+
import { ParticipantTimeline } from './ParticipantTimeline'
|
|
19
|
+
import { ParticipantThreadHeader } from './ParticipantThreadHeader'
|
|
20
|
+
import type { ParticipantSubjectGroup, ParticipantSubjectIconRenderer } from './participant.types'
|
|
24
21
|
|
|
25
22
|
export type ParticipantThreadDraft = {
|
|
26
23
|
readonly value: string
|
|
@@ -55,83 +52,16 @@ export type ParticipantThreadProps = {
|
|
|
55
52
|
readonly acceptedTypes?: readonly string[]
|
|
56
53
|
readonly maxLength?: number
|
|
57
54
|
readonly renderSubjectCard?: (conversation: ParticipantConversationSummary) => ReactNode
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
function resolveActions(item: ParticipantTimelineItem, pendingActions?: ParticipantPendingActions) {
|
|
69
|
-
if (!pendingActions || item.kind === 'server') return {}
|
|
70
|
-
return resolveParticipantBubbleActions(item, bindPendingActions(pendingActions, item.pending.clientMessageId))
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
function Header({ props }: { readonly props: ParticipantThreadProps }) {
|
|
74
|
-
const { conversation, labels, onBack, onOpenSubject } = props
|
|
75
|
-
const { subjectType, subjectId } = conversation
|
|
76
|
-
|
|
77
|
-
return (
|
|
78
|
-
<header className="cv-p-thread__header">
|
|
79
|
-
{onBack ? (
|
|
80
|
-
<button type="button" className="cv-p-button cv-p-thread__back" onClick={onBack}>
|
|
81
|
-
<span aria-hidden="true">‹</span>
|
|
82
|
-
<span className="cv-p-sr-only">{labels.back}</span>
|
|
83
|
-
</button>
|
|
84
|
-
) : null}
|
|
85
|
-
<div className="cv-p-thread__heading">
|
|
86
|
-
<h2 className="cv-p-thread__title">
|
|
87
|
-
{onOpenSubject ? (
|
|
88
|
-
<button
|
|
89
|
-
type="button"
|
|
90
|
-
className="cv-p-thread__title-button"
|
|
91
|
-
aria-label={`${labels.openSubject}: ${conversation.subjectLabel}`}
|
|
92
|
-
onClick={() => onOpenSubject({ subjectType, subjectId })}
|
|
93
|
-
>
|
|
94
|
-
{conversation.subjectLabel}
|
|
95
|
-
</button>
|
|
96
|
-
) : (
|
|
97
|
-
conversation.subjectLabel
|
|
98
|
-
)}
|
|
99
|
-
</h2>
|
|
100
|
-
{conversation.protocol ? <ParticipantProtocolBadge protocol={conversation.protocol} labels={labels} /> : null}
|
|
101
|
-
<ParticipantChannelBadges channels={conversation.channels} labels={labels} />
|
|
102
|
-
</div>
|
|
103
|
-
</header>
|
|
104
|
-
)
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
function Timeline({ props }: { readonly props: ParticipantThreadProps }) {
|
|
108
|
-
const { items, labels, locale, resolveAttachmentUrl, pendingActions } = props
|
|
109
|
-
const confirmsRead = channelCapabilityFor(props.channel ?? 'app').confirmsRead
|
|
110
|
-
const nodes: ReactNode[] = []
|
|
111
|
-
let previousDay: Date | undefined
|
|
112
|
-
|
|
113
|
-
for (const item of items) {
|
|
114
|
-
const day = new Date(createdAtOf(item))
|
|
115
|
-
if (previousDay === undefined || !isSameDay(previousDay, day)) {
|
|
116
|
-
nodes.push(
|
|
117
|
-
<div key={`day:${keyOf(item)}`} className="cv-p-day" role="separator">
|
|
118
|
-
{day.toLocaleDateString(locale, { weekday: 'short', day: '2-digit', month: '2-digit' })}
|
|
119
|
-
</div>,
|
|
120
|
-
)
|
|
121
|
-
}
|
|
122
|
-
previousDay = day
|
|
123
|
-
nodes.push(
|
|
124
|
-
<ParticipantMessageBubble
|
|
125
|
-
key={keyOf(item)}
|
|
126
|
-
item={item}
|
|
127
|
-
labels={labels}
|
|
128
|
-
confirmsRead={confirmsRead}
|
|
129
|
-
resolveAttachmentUrl={resolveAttachmentUrl}
|
|
130
|
-
{...resolveActions(item, pendingActions)}
|
|
131
|
-
/>,
|
|
132
|
-
)
|
|
133
|
-
}
|
|
134
|
-
return <div className="cv-p-thread__messages">{nodes}</div>
|
|
55
|
+
/** Source of the eyebrow above the title: the label of the group of the conversation's subject type. */
|
|
56
|
+
readonly subjectGroups?: readonly ParticipantSubjectGroup[]
|
|
57
|
+
/** Absent draws no avatar. The SDK has no photo: the host supplies it through renderAuthorAvatar. */
|
|
58
|
+
readonly avatars?: 'initials'
|
|
59
|
+
/** Host slot for the author's photo; wins over 'initials' and enables avatars on its own. */
|
|
60
|
+
readonly renderAuthorAvatar?: ParticipantAuthorAvatarRenderer
|
|
61
|
+
/** Host icon of the conversation avatar in the header; absent or empty falls back to the subject group icon. */
|
|
62
|
+
readonly renderSubjectIcon?: ParticipantSubjectIconRenderer
|
|
63
|
+
/** Speech-bubble tail on the bottom corner; false removes it. Default true. */
|
|
64
|
+
readonly tail?: boolean
|
|
135
65
|
}
|
|
136
66
|
|
|
137
67
|
function Footer({ props }: { readonly props: ParticipantThreadProps }) {
|
|
@@ -156,16 +86,23 @@ function Footer({ props }: { readonly props: ParticipantThreadProps }) {
|
|
|
156
86
|
)
|
|
157
87
|
}
|
|
158
88
|
|
|
159
|
-
|
|
89
|
+
function ThreadBody(props: ParticipantThreadProps) {
|
|
160
90
|
const { conversation, labels, hasMore, onLoadOlder, renderSubjectCard } = props
|
|
161
91
|
const subjectCard = renderSubjectCard?.(conversation)
|
|
162
92
|
const loadView = resolveLoadView({ status: props.status, hasItems: props.items.length > 0 })
|
|
163
93
|
|
|
164
94
|
return (
|
|
165
95
|
<div className="cv-p cv-p-thread" aria-busy={loadView.isLoading}>
|
|
166
|
-
<
|
|
96
|
+
<ParticipantThreadHeader
|
|
97
|
+
conversation={conversation}
|
|
98
|
+
labels={labels}
|
|
99
|
+
subjectGroups={props.subjectGroups}
|
|
100
|
+
renderSubjectIcon={props.renderSubjectIcon}
|
|
101
|
+
onBack={props.onBack}
|
|
102
|
+
onOpenSubject={props.onOpenSubject}
|
|
103
|
+
/>
|
|
167
104
|
{subjectCard ? <div className="cv-p-thread__subject-card">{subjectCard}</div> : null}
|
|
168
|
-
<div className="cv-p-thread__scroll" ref={props.scroll.ref} onScroll={props.scroll.onScroll}>
|
|
105
|
+
<div className="cv-p-thread__scroll" ref={props.scroll.ref} onScroll={props.scroll.onScroll} tabIndex={-1}>
|
|
169
106
|
{loadView.isLoading ? <ParticipantLoading labels={labels} /> : null}
|
|
170
107
|
{loadView.hasError ? <ParticipantLoadError labels={labels} onRetry={props.refresh} /> : null}
|
|
171
108
|
{hasMore && onLoadOlder ? (
|
|
@@ -173,12 +110,25 @@ export function ParticipantThread(props: ParticipantThreadProps) {
|
|
|
173
110
|
{labels.loadOlder}
|
|
174
111
|
</button>
|
|
175
112
|
) : null}
|
|
176
|
-
<
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
113
|
+
<ParticipantTimeline {...props} />
|
|
114
|
+
<ParticipantScrollToLatest
|
|
115
|
+
label={labels.scrollToLatest}
|
|
116
|
+
isAwayFromBottom={props.scroll.isAwayFromBottom === true}
|
|
117
|
+
hasMessages={props.items.length > 0}
|
|
118
|
+
newMessagesCount={props.newMessagesCount}
|
|
119
|
+
onClick={props.scroll.scrollToLatest}
|
|
120
|
+
/>
|
|
180
121
|
</div>
|
|
122
|
+
<ParticipantThreadLive notice={props.newMessagesCount > 0 ? labels.newMessages : ''} />
|
|
181
123
|
<Footer props={props} />
|
|
182
124
|
</div>
|
|
183
125
|
)
|
|
184
126
|
}
|
|
127
|
+
|
|
128
|
+
export function ParticipantThread(props: ParticipantThreadProps) {
|
|
129
|
+
return (
|
|
130
|
+
<ParticipantCopyAnnouncer>
|
|
131
|
+
<ThreadBody {...props} />
|
|
132
|
+
</ParticipantCopyAnnouncer>
|
|
133
|
+
)
|
|
134
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test'
|
|
2
|
+
import { renderToStaticMarkup } from 'react-dom/server'
|
|
3
|
+
|
|
4
|
+
import { buildConversation, buildMessage } from './participantFixtures.test-helper'
|
|
5
|
+
import { DEFAULT_PARTICIPANT_CONVERSATIONS_LABELS } from './participantLabels'
|
|
6
|
+
import type { ParticipantTimelineItem } from './participantMessages'
|
|
7
|
+
import { ParticipantThread } from './ParticipantThread'
|
|
8
|
+
|
|
9
|
+
function ana(id: string, createdAt: string): ParticipantTimelineItem {
|
|
10
|
+
return { kind: 'server', message: buildMessage({ id, authorName: 'Ana Souza', createdAt }) }
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function mine(id: string, createdAt: string): ParticipantTimelineItem {
|
|
14
|
+
return { kind: 'server', message: buildMessage({ id, direction: 'inbound', authorName: 'Ana Souza', createdAt }) }
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function render(items: readonly ParticipantTimelineItem[]): string {
|
|
18
|
+
return renderToStaticMarkup(
|
|
19
|
+
<ParticipantThread
|
|
20
|
+
conversation={buildConversation({ subjectId: '1' })}
|
|
21
|
+
items={items}
|
|
22
|
+
hasMore={false}
|
|
23
|
+
labels={DEFAULT_PARTICIPANT_CONVERSATIONS_LABELS}
|
|
24
|
+
resolveAttachmentUrl={async () => 'https://files.example/a'}
|
|
25
|
+
draft={{ value: '', onChange: () => undefined, files: [], onFilesChange: () => undefined }}
|
|
26
|
+
onSend={() => undefined}
|
|
27
|
+
status="ready"
|
|
28
|
+
refresh={() => undefined}
|
|
29
|
+
scroll={{ ref: { current: null }, onScroll: () => undefined }}
|
|
30
|
+
newMessagesCount={0}
|
|
31
|
+
isSending={false}
|
|
32
|
+
avatars="initials"
|
|
33
|
+
/>,
|
|
34
|
+
)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function countAvatars(markup: string): number {
|
|
38
|
+
return markup.match(/class="cv-p-avatar" aria-hidden="true">AS</g)?.length ?? 0
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
describe('avatar sequence in the thread', () => {
|
|
42
|
+
it('shows one avatar for consecutive messages of the same author', () => {
|
|
43
|
+
const markup = render([ana('a', '2026-10-01T10:00:00.000Z'), ana('b', '2026-10-01T10:01:00.000Z')])
|
|
44
|
+
expect(countAvatars(markup)).toBe(1)
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('shows the avatar again after an own message of the same author in between', () => {
|
|
48
|
+
const markup = render([
|
|
49
|
+
ana('a', '2026-10-01T10:00:00.000Z'),
|
|
50
|
+
mine('b', '2026-10-01T10:01:00.000Z'),
|
|
51
|
+
ana('c', '2026-10-01T10:02:00.000Z'),
|
|
52
|
+
])
|
|
53
|
+
expect(countAvatars(markup)).toBe(2)
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('shows the avatar again after the day separator', () => {
|
|
57
|
+
const markup = render([ana('a', '2026-10-01T10:00:00.000Z'), ana('b', '2026-10-03T10:00:00.000Z')])
|
|
58
|
+
expect(markup).toMatch(/role="separator" aria-label="[^"]+"/)
|
|
59
|
+
expect(countAvatars(markup)).toBe(2)
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('never draws the avatar on own messages', () => {
|
|
63
|
+
expect(render([mine('a', '2026-10-01T10:00:00.000Z')])).not.toContain('cv-p-avatar')
|
|
64
|
+
})
|
|
65
|
+
})
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
import { describe, expect, it } from 'bun:test'
|
|
4
|
+
import { renderToStaticMarkup } from 'react-dom/server'
|
|
5
|
+
|
|
6
|
+
import { splitBlocks } from '../cssBlocks.test-helper'
|
|
7
|
+
import { buildConversation } from './participantFixtures.test-helper'
|
|
8
|
+
import { DEFAULT_PARTICIPANT_CONVERSATIONS_LABELS } from './participantLabels'
|
|
9
|
+
import { ParticipantProtocolBadgeView } from './ParticipantProtocolBadge'
|
|
10
|
+
import { ParticipantThreadHeader, type ParticipantThreadHeaderProps } from './ParticipantThreadHeader'
|
|
11
|
+
|
|
12
|
+
const LABELS = DEFAULT_PARTICIPANT_CONVERSATIONS_LABELS
|
|
13
|
+
const GROUPS = [{ subjectType: 'invoice', label: 'Invoices' }]
|
|
14
|
+
|
|
15
|
+
function render(overrides: Partial<ParticipantThreadHeaderProps> = {}): string {
|
|
16
|
+
return renderToStaticMarkup(
|
|
17
|
+
<ParticipantThreadHeader
|
|
18
|
+
conversation={buildConversation({ subjectId: '1', subjectLabel: 'Item 4521' })}
|
|
19
|
+
labels={LABELS}
|
|
20
|
+
{...overrides}
|
|
21
|
+
/>,
|
|
22
|
+
)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function metaOf(markup: string): string {
|
|
26
|
+
const start = markup.indexOf('class="cv-p-thread__meta"')
|
|
27
|
+
const end = markup.indexOf('</header>', start)
|
|
28
|
+
return start === -1 ? '' : markup.slice(start, end)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
describe('thread header structure', () => {
|
|
32
|
+
const conversation = buildConversation({
|
|
33
|
+
subjectId: '1',
|
|
34
|
+
subjectLabel: 'Item 4521',
|
|
35
|
+
protocol: '261009-K7M2',
|
|
36
|
+
channels: ['app', 'whatsapp'],
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it('draws the eyebrow only when the subject type has a group', () => {
|
|
40
|
+
expect(render({ subjectGroups: GROUPS })).toContain('<p class="cv-p-thread__eyebrow">Invoices</p>')
|
|
41
|
+
expect(render()).not.toContain('cv-p-thread__eyebrow')
|
|
42
|
+
expect(render({ subjectGroups: [{ subjectType: 'other', label: 'Other' }] })).not.toContain('cv-p-thread__eyebrow')
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('draws the title, clickable only with onOpenSubject', () => {
|
|
46
|
+
expect(render()).toContain('Item 4521')
|
|
47
|
+
expect(render()).not.toContain('cv-p-thread__title-button')
|
|
48
|
+
expect(render({ onOpenSubject: () => undefined })).toContain('cv-p-thread__title-button')
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('keeps protocol, copy button and channel badges in one single meta line', () => {
|
|
52
|
+
const markup = render({ conversation })
|
|
53
|
+
expect(markup.match(/cv-p-thread__meta"/g)?.length).toBe(1)
|
|
54
|
+
const meta = metaOf(markup)
|
|
55
|
+
expect(meta).toContain('261009-K7M2')
|
|
56
|
+
expect(meta).toContain('cv-p-protocol__copy')
|
|
57
|
+
expect(meta).toContain('cv-p-channel--app')
|
|
58
|
+
expect(meta).toContain('cv-p-channel--whatsapp')
|
|
59
|
+
expect(markup.match(/cv-p-channels/g)?.length).toBe(1)
|
|
60
|
+
expect(markup.indexOf('cv-p-channels')).toBeGreaterThan(markup.indexOf('cv-p-thread__meta'))
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('does not draw a separate channels row', () => {
|
|
64
|
+
const markup = render({ conversation })
|
|
65
|
+
expect(markup).not.toContain('<ul')
|
|
66
|
+
expect(markup).toContain('<span class="cv-p-channels">')
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('draws the meta line with channels only when there is no protocol', () => {
|
|
70
|
+
const markup = render({ conversation: buildConversation({ subjectId: '1', channels: ['app'] }) })
|
|
71
|
+
expect(metaOf(markup)).toContain('cv-p-channel--app')
|
|
72
|
+
expect(markup).not.toContain('cv-p-protocol')
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('draws no meta line without protocol and without channels', () => {
|
|
76
|
+
const markup = render()
|
|
77
|
+
expect(markup).not.toContain('cv-p-thread__meta')
|
|
78
|
+
expect(markup).not.toContain('cv-p-protocol')
|
|
79
|
+
expect(markup).not.toContain('cv-p-channels')
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it('draws the back button only with onBack', () => {
|
|
83
|
+
expect(render()).not.toContain('cv-p-thread__back')
|
|
84
|
+
expect(render({ onBack: () => undefined })).toContain('cv-p-thread__back')
|
|
85
|
+
})
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
describe('copy button as an icon', () => {
|
|
89
|
+
const view = (isCopied: boolean) =>
|
|
90
|
+
renderToStaticMarkup(
|
|
91
|
+
<ParticipantProtocolBadgeView
|
|
92
|
+
protocol="261009-K7M2"
|
|
93
|
+
labels={LABELS}
|
|
94
|
+
copyState={{ copyCount: isCopied ? 1 : 0, isCopied }}
|
|
95
|
+
onCopy={() => undefined}
|
|
96
|
+
/>,
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
it('has the full accessible name and no visible text', () => {
|
|
100
|
+
const markup = view(false)
|
|
101
|
+
expect(markup).toContain('aria-label="Copy protocol 261009-K7M2"')
|
|
102
|
+
const button = markup.slice(markup.indexOf('<button'), markup.indexOf('</button>'))
|
|
103
|
+
expect(button).toContain('<svg')
|
|
104
|
+
expect(button.replace(/<[^>]+>/g, '')).toBe('')
|
|
105
|
+
expect(markup.replace(/aria-label="[^"]*"/g, '')).not.toContain('Copy protocol')
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
it('shows the short confirmation next to the icon while copied', () => {
|
|
109
|
+
const markup = view(true)
|
|
110
|
+
const button = markup.slice(markup.indexOf('<button'), markup.indexOf('</button>'))
|
|
111
|
+
expect(button).toContain(`<span class="cv-p-protocol__copied" aria-hidden="true">${LABELS.protocolCopied}</span>`)
|
|
112
|
+
expect(markup).toMatch(/aria-live="polite"><span>Protocol copied<\/span><\/div>/)
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
it('keeps the live region outside the protocol block', () => {
|
|
116
|
+
const markup = view(true)
|
|
117
|
+
const block = markup.slice(markup.indexOf('cv-p-protocol--header'), markup.indexOf('</button>'))
|
|
118
|
+
expect(block).not.toContain('aria-live')
|
|
119
|
+
})
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
const CSS = readFileSync(join(import.meta.dir, '..', 'styles.css'), 'utf8').replace(/\/\*[\s\S]*?\*\//g, '')
|
|
123
|
+
|
|
124
|
+
function declarationsOf(selector: string): Map<string, string> {
|
|
125
|
+
const declarations = new Map<string, string>()
|
|
126
|
+
for (const { prelude, body } of splitBlocks(CSS)) {
|
|
127
|
+
if (body === undefined || prelude.startsWith('@')) continue
|
|
128
|
+
if (!prelude.split(',').some((entry) => entry.trim() === selector)) continue
|
|
129
|
+
for (const declaration of body.split(';')) {
|
|
130
|
+
const separator = declaration.indexOf(':')
|
|
131
|
+
if (separator === -1) continue
|
|
132
|
+
declarations.set(declaration.slice(0, separator).trim().toLowerCase(), declaration.slice(separator + 1).trim())
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return declarations
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function toRem(value: string | undefined): number {
|
|
139
|
+
const match = /^(\d*\.?\d+)rem$/.exec(value ?? '')
|
|
140
|
+
return match ? Number(match[1]) : 0
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
describe('thread header CSS', () => {
|
|
144
|
+
it('lays the meta line out as a wrapping flex row', () => {
|
|
145
|
+
const meta = declarationsOf('.cv-p-thread__meta')
|
|
146
|
+
expect(meta.get('display')).toBe('flex')
|
|
147
|
+
expect(meta.get('flex-wrap')).toBe('wrap')
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
it('gives the copy button a wide touch area without inflating the line', () => {
|
|
151
|
+
const copy = declarationsOf('.cv-p-protocol__copy')
|
|
152
|
+
expect(toRem(copy.get('min-width'))).toBeGreaterThanOrEqual(2.75)
|
|
153
|
+
expect(toRem(copy.get('min-height'))).toBeGreaterThanOrEqual(2)
|
|
154
|
+
expect(copy.get('margin')).toMatch(/^-/)
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
it('sets a floor of 4rem on the header but never a fixed or maximum height', () => {
|
|
158
|
+
const header = declarationsOf('.cv-p-thread__header')
|
|
159
|
+
for (const property of ['height', 'max-height']) expect(header.has(property)).toBe(false)
|
|
160
|
+
expect(toRem(header.get('min-height'))).toBeGreaterThanOrEqual(4)
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
it('clamps the title to one line', () => {
|
|
164
|
+
expect(declarationsOf('.cv-p-thread__title-text').get('-webkit-line-clamp')).toBe('1')
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
it('draws the eyebrow like the list row kind label', () => {
|
|
168
|
+
const eyebrow = declarationsOf('.cv-p-thread__eyebrow')
|
|
169
|
+
expect(eyebrow.get('text-transform')).toBe('uppercase')
|
|
170
|
+
expect(eyebrow.get('font')).toContain('monospace')
|
|
171
|
+
})
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
describe('avatar CSS', () => {
|
|
175
|
+
it('draws a 2rem box that follows the host radius token', () => {
|
|
176
|
+
const avatar = declarationsOf('.cv-p-avatar')
|
|
177
|
+
expect(avatar.get('width')).toBe('2rem')
|
|
178
|
+
expect(avatar.get('height')).toBe('2rem')
|
|
179
|
+
expect(avatar.get('border-radius')).toBe('var(--cv-p-i-radius)')
|
|
180
|
+
})
|
|
181
|
+
})
|