@adatechnology/conversations-ui 0.6.1 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +221 -19
- package/dist/{ConversationSimulatorPanel-CrfFjiWh.d.ts → ConversationSimulatorPanel-q-wVgBCN.d.ts} +1 -1
- 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.d.ts +4 -4
- package/dist/index.js +834 -764
- package/dist/participant/index.d.ts +62 -3
- package/dist/participant/index.js +1444 -394
- package/dist/preview/index.d.ts +3 -3
- package/dist/preview/index.js +2 -3
- package/dist/{quickReply.types-B4gmZsSr.d.ts → quickReply.types-DzWH3NYV.d.ts} +1 -1
- package/dist/styles.css +629 -20
- package/package.json +3 -3
- package/src/participant/ConversationThread.test.tsx +165 -0
- package/src/participant/ConversationThread.tsx +117 -0
- 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 +32 -84
- 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 +54 -95
- package/src/participant/ParticipantThreadAvatarSequence.test.tsx +65 -0
- package/src/participant/ParticipantThreadHeader.test.tsx +181 -0
- package/src/participant/ParticipantThreadHeader.tsx +83 -0
- package/src/participant/ParticipantThreadScreen.tsx +30 -56
- package/src/participant/ParticipantTimeline.tsx +106 -0
- package/src/participant/conversationThreadApi.test.ts +27 -0
- package/src/participant/conversationThreadApi.ts +24 -0
- package/src/participant/conversationThreadCss.test.ts +116 -0
- package/src/participant/conversationThreadSummary.ts +34 -0
- package/src/participant/index.ts +4 -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/participantContrast.test-helper.ts +116 -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/participantGolden.test.tsx +46 -0
- package/src/participant/participantGoldenScenarios.test-helper.tsx +57 -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/participantMessages.ts +4 -3
- package/src/participant/participantNewMessages.test.ts +21 -0
- package/src/participant/participantNewMessages.ts +8 -3
- package/src/participant/participantPerspective.test.ts +67 -0
- package/src/participant/participantPerspective.ts +50 -0
- package/src/participant/participantPerspectiveRender.test.tsx +94 -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/participantThemeContrast.test.ts +98 -0
- package/src/participant/test-fixtures/conversations-list.golden.html +1 -0
- package/src/participant/test-fixtures/conversations-selected.golden.html +1 -0
- package/src/participant/test-fixtures/thread-avatars-initials.golden.html +1 -0
- package/src/participant/test-fixtures/thread-closed.golden.html +1 -0
- package/src/participant/test-fixtures/thread-default.golden.html +1 -0
- package/src/participant/test-fixtures/thread-error.golden.html +1 -0
- package/src/participant/test-fixtures/thread-loading.golden.html +1 -0
- package/src/participant/test-fixtures/thread-plain.golden.html +1 -0
- package/src/participant/test-fixtures/thread-tail-off.golden.html +1 -0
- package/src/participant/useParticipantConversationView.ts +3 -0
- package/src/participant/useParticipantCopyFeedback.ts +33 -0
- package/src/participant/useParticipantThread.ts +13 -2
- package/src/participant/useParticipantThreadController.ts +112 -0
- package/src/participant/useParticipantThreadScroll.ts +13 -3
- package/src/participant/useParticipantUnread.test.tsx +40 -0
- package/src/participant/useParticipantUnread.ts +38 -0
- 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 +151 -23
- package/dist/chunk-MRVFFJEH.js +0 -64
- package/dist/chunk-OVFCLRQQ.js +0 -84
|
@@ -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
|
+
})
|
|
@@ -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,23 @@ 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'
|
|
15
|
+
import type { ParticipantPerspective } from './participantPerspective'
|
|
19
16
|
import type { ParticipantThreadScroll } from './useStickToBottom'
|
|
20
|
-
import type {
|
|
21
|
-
import {
|
|
22
|
-
import {
|
|
23
|
-
import {
|
|
17
|
+
import type { ParticipantAuthorAvatarRenderer } from './ParticipantAuthorAvatar'
|
|
18
|
+
import { ParticipantScrollToLatest } from './ParticipantScrollToLatest'
|
|
19
|
+
import { ParticipantTimeline } from './ParticipantTimeline'
|
|
20
|
+
import { ParticipantThreadHeader } from './ParticipantThreadHeader'
|
|
21
|
+
import type { ParticipantSubjectGroup, ParticipantSubjectIconRenderer } from './participant.types'
|
|
24
22
|
|
|
25
23
|
export type ParticipantThreadDraft = {
|
|
26
24
|
readonly value: string
|
|
@@ -55,83 +53,22 @@ export type ParticipantThreadProps = {
|
|
|
55
53
|
readonly acceptedTypes?: readonly string[]
|
|
56
54
|
readonly maxLength?: number
|
|
57
55
|
readonly renderSubjectCard?: (conversation: ParticipantConversationSummary) => ReactNode
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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>
|
|
56
|
+
/** Source of the eyebrow above the title: the label of the group of the conversation's subject type. */
|
|
57
|
+
readonly subjectGroups?: readonly ParticipantSubjectGroup[]
|
|
58
|
+
/** Absent draws no avatar. The SDK has no photo: the host supplies it through renderAuthorAvatar. */
|
|
59
|
+
readonly avatars?: 'initials'
|
|
60
|
+
/** Host slot for the author's photo; wins over 'initials' and enables avatars on its own. */
|
|
61
|
+
readonly renderAuthorAvatar?: ParticipantAuthorAvatarRenderer
|
|
62
|
+
/** Host icon of the conversation avatar in the header; absent or empty falls back to the subject group icon. */
|
|
63
|
+
readonly renderSubjectIcon?: ParticipantSubjectIconRenderer
|
|
64
|
+
/** Speech-bubble tail on the bottom corner; false removes it. Default true. */
|
|
65
|
+
readonly tail?: boolean
|
|
66
|
+
/** Who is looking; absent is the participant, so nothing changes for the existing screen. */
|
|
67
|
+
readonly perspective?: ParticipantPerspective
|
|
68
|
+
/** Short line under the title, written by the host. */
|
|
69
|
+
readonly counterpartLabel?: string
|
|
70
|
+
/** Host slot at the end of the header bar (close, reopen...). */
|
|
71
|
+
readonly headerActions?: ReactNode
|
|
135
72
|
}
|
|
136
73
|
|
|
137
74
|
function Footer({ props }: { readonly props: ParticipantThreadProps }) {
|
|
@@ -156,16 +93,25 @@ function Footer({ props }: { readonly props: ParticipantThreadProps }) {
|
|
|
156
93
|
)
|
|
157
94
|
}
|
|
158
95
|
|
|
159
|
-
|
|
96
|
+
function ThreadBody(props: ParticipantThreadProps) {
|
|
160
97
|
const { conversation, labels, hasMore, onLoadOlder, renderSubjectCard } = props
|
|
161
98
|
const subjectCard = renderSubjectCard?.(conversation)
|
|
162
99
|
const loadView = resolveLoadView({ status: props.status, hasItems: props.items.length > 0 })
|
|
163
100
|
|
|
164
101
|
return (
|
|
165
102
|
<div className="cv-p cv-p-thread" aria-busy={loadView.isLoading}>
|
|
166
|
-
<
|
|
103
|
+
<ParticipantThreadHeader
|
|
104
|
+
conversation={conversation}
|
|
105
|
+
labels={labels}
|
|
106
|
+
subjectGroups={props.subjectGroups}
|
|
107
|
+
renderSubjectIcon={props.renderSubjectIcon}
|
|
108
|
+
onBack={props.onBack}
|
|
109
|
+
onOpenSubject={props.onOpenSubject}
|
|
110
|
+
counterpartLabel={props.counterpartLabel}
|
|
111
|
+
actions={props.headerActions}
|
|
112
|
+
/>
|
|
167
113
|
{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}>
|
|
114
|
+
<div className="cv-p-thread__scroll" ref={props.scroll.ref} onScroll={props.scroll.onScroll} tabIndex={-1}>
|
|
169
115
|
{loadView.isLoading ? <ParticipantLoading labels={labels} /> : null}
|
|
170
116
|
{loadView.hasError ? <ParticipantLoadError labels={labels} onRetry={props.refresh} /> : null}
|
|
171
117
|
{hasMore && onLoadOlder ? (
|
|
@@ -173,12 +119,25 @@ export function ParticipantThread(props: ParticipantThreadProps) {
|
|
|
173
119
|
{labels.loadOlder}
|
|
174
120
|
</button>
|
|
175
121
|
) : null}
|
|
176
|
-
<
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
122
|
+
<ParticipantTimeline {...props} />
|
|
123
|
+
<ParticipantScrollToLatest
|
|
124
|
+
label={labels.scrollToLatest}
|
|
125
|
+
isAwayFromBottom={props.scroll.isAwayFromBottom === true}
|
|
126
|
+
hasMessages={props.items.length > 0}
|
|
127
|
+
newMessagesCount={props.newMessagesCount}
|
|
128
|
+
onClick={props.scroll.scrollToLatest}
|
|
129
|
+
/>
|
|
180
130
|
</div>
|
|
131
|
+
<ParticipantThreadLive notice={props.newMessagesCount > 0 ? labels.newMessages : ''} />
|
|
181
132
|
<Footer props={props} />
|
|
182
133
|
</div>
|
|
183
134
|
)
|
|
184
135
|
}
|
|
136
|
+
|
|
137
|
+
export function ParticipantThread(props: ParticipantThreadProps) {
|
|
138
|
+
return (
|
|
139
|
+
<ParticipantCopyAnnouncer>
|
|
140
|
+
<ThreadBody {...props} />
|
|
141
|
+
</ParticipantCopyAnnouncer>
|
|
142
|
+
)
|
|
143
|
+
}
|