@adatechnology/conversations-ui 0.4.2 → 0.6.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 +231 -0
- package/dist/{ConversationSimulatorPanel-DnXElfY8.d.ts → ConversationSimulatorPanel-CrfFjiWh.d.ts} +2 -268
- package/dist/{chunk-PJNR6UMN.js → chunk-CBYEHPX6.js} +1 -63
- package/dist/{chunk-4P5KMWOI.js → chunk-FAQLC3TX.js} +327 -522
- package/dist/{chunk-HMCOTZAX.js → chunk-HOKNKXIT.js} +1 -1
- package/dist/chunk-MRVFFJEH.js +64 -0
- package/dist/chunk-O6457XHK.js +255 -0
- package/dist/chunk-OVFCLRQQ.js +84 -0
- package/dist/flows/index.js +5 -3
- package/dist/index.d.ts +21 -5
- package/dist/index.js +783 -807
- package/dist/participant/index.d.ts +186 -0
- package/dist/participant/index.js +1708 -0
- package/dist/preview/index.d.ts +4 -2
- package/dist/preview/index.js +4 -2
- package/dist/quickReply.types-B4gmZsSr.d.ts +274 -0
- package/dist/styles.css +689 -0
- package/package.json +8 -4
- package/src/ChannelIcon.tsx +2 -0
- package/src/MessageText.test.tsx +76 -0
- package/src/MessageText.tsx +28 -12
- package/src/MessageTextCopiedBadge.tsx +15 -0
- package/src/StatusTicks.test.tsx +51 -0
- package/src/StatusTicks.tsx +36 -6
- package/src/buildOutput.test.ts +41 -0
- package/src/channelCapability.test.ts +17 -0
- package/src/conversationChannel.test.ts +30 -1
- package/src/conversationChannel.ts +19 -1
- package/src/legacyChannels.golden.test.ts +89 -0
- package/src/legacyDefaults.golden.test.tsx +127 -0
- package/src/legacyStyles.fixture.css +1121 -0
- package/src/legacyStyles.golden.test.ts +121 -0
- package/src/messageTextCopyInteraction.test.ts +75 -0
- package/src/messageTextCopyInteraction.ts +37 -0
- package/src/participant/ParticipantAttachmentItem.tsx +77 -0
- package/src/participant/ParticipantChannelBadges.test.tsx +59 -0
- package/src/participant/ParticipantChannelBadges.tsx +75 -0
- package/src/participant/ParticipantComposer.test.tsx +97 -0
- package/src/participant/ParticipantComposer.tsx +167 -0
- package/src/participant/ParticipantConversations.test.tsx +90 -0
- package/src/participant/ParticipantConversations.tsx +49 -0
- package/src/participant/ParticipantConversationsScreen.tsx +78 -0
- package/src/participant/ParticipantInbox.test.tsx +244 -0
- package/src/participant/ParticipantInbox.tsx +149 -0
- package/src/participant/ParticipantInboxRow.tsx +75 -0
- package/src/participant/ParticipantInboxSearchField.tsx +34 -0
- package/src/participant/ParticipantLoadState.tsx +27 -0
- package/src/participant/ParticipantMessageBubble.test.tsx +167 -0
- package/src/participant/ParticipantMessageBubble.tsx +185 -0
- package/src/participant/ParticipantProtocolBadge.tsx +74 -0
- package/src/participant/ParticipantProtocolView.test.tsx +208 -0
- package/src/participant/ParticipantThread.test.tsx +149 -0
- package/src/participant/ParticipantThread.tsx +184 -0
- package/src/participant/ParticipantThreadScreen.tsx +135 -0
- package/src/participant/bindAttachmentUrlResolver.test.ts +25 -0
- package/src/participant/bindAttachmentUrlResolver.ts +7 -0
- package/src/participant/index.ts +26 -0
- package/src/participant/participant.types.ts +52 -0
- package/src/participant/participantApi.types.ts +59 -0
- package/src/participant/participantAttachmentUrl.test.ts +32 -0
- package/src/participant/participantAttachmentUrl.ts +10 -0
- package/src/participant/participantBubbleActions.test.ts +44 -0
- package/src/participant/participantBubbleActions.ts +40 -0
- package/src/participant/participantChannels.test.ts +42 -0
- package/src/participant/participantChannels.ts +33 -0
- package/src/participant/participantDrafts.test.ts +65 -0
- package/src/participant/participantDrafts.ts +50 -0
- package/src/participant/participantFileValidation.test.ts +86 -0
- package/src/participant/participantFileValidation.ts +72 -0
- package/src/participant/participantFixtures.test-helper.ts +41 -0
- package/src/participant/participantGrouping.test.ts +236 -0
- package/src/participant/participantGrouping.ts +90 -0
- package/src/participant/participantInboxState.test.ts +69 -0
- package/src/participant/participantInboxState.ts +74 -0
- package/src/participant/participantLabels.ts +115 -0
- package/src/participant/participantLoadView.test.ts +23 -0
- package/src/participant/participantLoadView.ts +21 -0
- package/src/participant/participantMessages.test.ts +128 -0
- package/src/participant/participantMessages.ts +73 -0
- package/src/participant/participantNewMessages.test.ts +54 -0
- package/src/participant/participantNewMessages.ts +29 -0
- package/src/participant/participantProtocol.test.ts +104 -0
- package/src/participant/participantProtocol.ts +52 -0
- package/src/participant/participantProtocolCopy.test.ts +45 -0
- package/src/participant/participantProtocolCopy.ts +32 -0
- package/src/participant/participantQuickReply.test.ts +23 -0
- package/src/participant/participantQuickReply.ts +6 -0
- package/src/participant/participantScroll.test.ts +69 -0
- package/src/participant/participantScroll.ts +43 -0
- package/src/participant/participantSendController.test.ts +201 -0
- package/src/participant/participantSendController.ts +134 -0
- package/src/participant/participantSendStates.ts +31 -0
- package/src/participant/participantTheme.ts +20 -0
- package/src/participant/participantThreadState.test.ts +75 -0
- package/src/participant/participantThreadState.ts +59 -0
- package/src/participant/shouldMarkRead.test.ts +24 -0
- package/src/participant/shouldMarkRead.ts +16 -0
- package/src/participant/useDocumentVisibility.ts +18 -0
- package/src/participant/useParticipantConversationView.ts +60 -0
- package/src/participant/useParticipantDraft.ts +45 -0
- package/src/participant/useParticipantInbox.ts +57 -0
- package/src/participant/useParticipantMarkRead.ts +26 -0
- package/src/participant/useParticipantRevalidation.ts +36 -0
- package/src/participant/useParticipantSend.ts +84 -0
- package/src/participant/useParticipantThread.ts +116 -0
- package/src/participant/useParticipantThreadScroll.ts +26 -0
- package/src/participant/useStickToBottom.ts +77 -0
- package/src/preview/ConversationSimulatorPanel.tsx +8 -0
- package/src/styles.css +178 -0
- package/src/types.ts +3 -1
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { useEffect, useReducer } from 'react'
|
|
2
|
+
|
|
3
|
+
import type { ParticipantConversationsLabels } from './participantLabels'
|
|
4
|
+
import {
|
|
5
|
+
INITIAL_PROTOCOL_COPY_STATE,
|
|
6
|
+
copyProtocolToClipboard,
|
|
7
|
+
protocolCopyReducer,
|
|
8
|
+
type ProtocolCopyState,
|
|
9
|
+
} from './participantProtocolCopy'
|
|
10
|
+
|
|
11
|
+
const COPIED_ANNOUNCEMENT_MS = 3000
|
|
12
|
+
|
|
13
|
+
export type ParticipantProtocolBadgeProps = {
|
|
14
|
+
readonly protocol: string
|
|
15
|
+
readonly labels: ParticipantConversationsLabels
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export type ParticipantProtocolBadgeViewProps = ParticipantProtocolBadgeProps & {
|
|
19
|
+
readonly copyState: ProtocolCopyState
|
|
20
|
+
readonly onCopy: () => void
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function ParticipantProtocolBadgeView({
|
|
24
|
+
protocol,
|
|
25
|
+
labels,
|
|
26
|
+
copyState,
|
|
27
|
+
onCopy,
|
|
28
|
+
}: ParticipantProtocolBadgeViewProps) {
|
|
29
|
+
const { isCopied, copyCount } = copyState
|
|
30
|
+
return (
|
|
31
|
+
<>
|
|
32
|
+
<div className="cv-p-protocol cv-p-protocol--header">
|
|
33
|
+
<span className="cv-p-protocol__code">
|
|
34
|
+
<span className="cv-p-sr-only">{labels.protocolPrefix} </span>
|
|
35
|
+
{protocol}
|
|
36
|
+
</span>
|
|
37
|
+
<button type="button" className="cv-p-protocol__copy" onClick={onCopy}>
|
|
38
|
+
<span aria-hidden="true">{isCopied ? labels.protocolCopied : labels.copyProtocol}</span>
|
|
39
|
+
<span className="cv-p-sr-only">
|
|
40
|
+
{labels.copyProtocol} {protocol}
|
|
41
|
+
</span>
|
|
42
|
+
</button>
|
|
43
|
+
</div>
|
|
44
|
+
<div className="cv-p-sr-only" role="status" aria-live="polite">
|
|
45
|
+
{isCopied ? <span key={copyCount}>{labels.protocolCopied}</span> : null}
|
|
46
|
+
</div>
|
|
47
|
+
</>
|
|
48
|
+
)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function ParticipantProtocolBadge({ protocol, labels }: ParticipantProtocolBadgeProps) {
|
|
52
|
+
const [copyState, dispatch] = useReducer(protocolCopyReducer, INITIAL_PROTOCOL_COPY_STATE)
|
|
53
|
+
const { isCopied, copyCount } = copyState
|
|
54
|
+
|
|
55
|
+
useEffect(() => {
|
|
56
|
+
if (!isCopied) return undefined
|
|
57
|
+
const timer = setTimeout(() => dispatch({ type: 'expired' }), COPIED_ANNOUNCEMENT_MS)
|
|
58
|
+
return () => clearTimeout(timer)
|
|
59
|
+
}, [isCopied, copyCount])
|
|
60
|
+
|
|
61
|
+
async function handleCopy(): Promise<void> {
|
|
62
|
+
const clipboard = typeof navigator === 'undefined' ? undefined : navigator.clipboard
|
|
63
|
+
if (await copyProtocolToClipboard(protocol, clipboard)) dispatch({ type: 'copied' })
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return (
|
|
67
|
+
<ParticipantProtocolBadgeView
|
|
68
|
+
protocol={protocol}
|
|
69
|
+
labels={labels}
|
|
70
|
+
copyState={copyState}
|
|
71
|
+
onCopy={() => void handleCopy()}
|
|
72
|
+
/>
|
|
73
|
+
)
|
|
74
|
+
}
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test'
|
|
2
|
+
import { readFileSync, readdirSync } from 'node:fs'
|
|
3
|
+
import { fileURLToPath } from 'node:url'
|
|
4
|
+
import { renderToStaticMarkup } from 'react-dom/server'
|
|
5
|
+
|
|
6
|
+
import { buildConversation, findForeignClassTokens, findUtilityClassTokens } from './participantFixtures.test-helper'
|
|
7
|
+
import { groupParticipantConversations } from './participantGrouping'
|
|
8
|
+
import { DEFAULT_PARTICIPANT_CONVERSATIONS_LABELS } from './participantLabels'
|
|
9
|
+
import { ParticipantInbox } from './ParticipantInbox'
|
|
10
|
+
import { ParticipantProtocolBadgeView } from './ParticipantProtocolBadge'
|
|
11
|
+
import { ParticipantThread } from './ParticipantThread'
|
|
12
|
+
|
|
13
|
+
const LABELS = DEFAULT_PARTICIPANT_CONVERSATIONS_LABELS
|
|
14
|
+
const WITH_PROTOCOL = buildConversation({ subjectId: '1', subjectLabel: 'Item 4521', protocol: '261009-K7M2' })
|
|
15
|
+
const WITHOUT_PROTOCOL = buildConversation({ subjectId: '2', subjectLabel: 'Item 9999' })
|
|
16
|
+
|
|
17
|
+
type InboxOverrides = {
|
|
18
|
+
search?: { value: string; isVisible: boolean }
|
|
19
|
+
hasMore?: boolean
|
|
20
|
+
filter?: string
|
|
21
|
+
hasMatchesOutsideFilter?: boolean
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function renderInbox(conversations: readonly (typeof WITH_PROTOCOL)[], overrides: InboxOverrides = {}): string {
|
|
25
|
+
const view = {
|
|
26
|
+
...groupParticipantConversations({ conversations, subjectGroups: [], filter: 'all' }),
|
|
27
|
+
...(overrides.hasMatchesOutsideFilter === undefined
|
|
28
|
+
? {}
|
|
29
|
+
: { hasMatchesOutsideFilter: overrides.hasMatchesOutsideFilter }),
|
|
30
|
+
}
|
|
31
|
+
return renderToStaticMarkup(
|
|
32
|
+
<ParticipantInbox
|
|
33
|
+
view={view}
|
|
34
|
+
subjectGroups={[]}
|
|
35
|
+
filter="all"
|
|
36
|
+
onFilterChange={() => undefined}
|
|
37
|
+
onSelect={() => undefined}
|
|
38
|
+
labels={LABELS}
|
|
39
|
+
status="ready"
|
|
40
|
+
refresh={() => undefined}
|
|
41
|
+
hasMore={overrides.hasMore ?? false}
|
|
42
|
+
loadMore={() => undefined}
|
|
43
|
+
search={overrides.search ? { ...overrides.search, onChange: () => undefined } : undefined}
|
|
44
|
+
/>,
|
|
45
|
+
)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function renderThread(conversation: typeof WITH_PROTOCOL): string {
|
|
49
|
+
return renderToStaticMarkup(
|
|
50
|
+
<ParticipantThread
|
|
51
|
+
conversation={conversation}
|
|
52
|
+
items={[]}
|
|
53
|
+
hasMore={false}
|
|
54
|
+
labels={LABELS}
|
|
55
|
+
resolveAttachmentUrl={async () => 'https://files.example/a'}
|
|
56
|
+
draft={{ value: '', onChange: () => undefined, files: [], onFilesChange: () => undefined }}
|
|
57
|
+
onSend={() => undefined}
|
|
58
|
+
status="ready"
|
|
59
|
+
refresh={() => undefined}
|
|
60
|
+
scroll={{ ref: { current: null }, onScroll: () => undefined }}
|
|
61
|
+
newMessagesCount={0}
|
|
62
|
+
isSending={false}
|
|
63
|
+
/>,
|
|
64
|
+
)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
describe('protocol in the inbox row', () => {
|
|
68
|
+
it('shows the protocol with an accessible name', () => {
|
|
69
|
+
const markup = renderInbox([WITH_PROTOCOL])
|
|
70
|
+
expect(markup).toContain('cv-p-protocol')
|
|
71
|
+
expect(markup).toContain('261009-K7M2')
|
|
72
|
+
expect(markup).toContain('<span class="cv-p-sr-only">Protocol </span>261009-K7M2')
|
|
73
|
+
expect(markup).not.toMatch(/<span[^>]*aria-label/)
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it('draws nothing when the protocol is absent', () => {
|
|
77
|
+
expect(renderInbox([WITHOUT_PROTOCOL])).not.toContain('cv-p-protocol')
|
|
78
|
+
})
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
describe('inbox search', () => {
|
|
82
|
+
it('is drawn with a label, placeholder and type=search when visible', () => {
|
|
83
|
+
const markup = renderInbox([WITH_PROTOCOL], { search: { value: '', isVisible: true } })
|
|
84
|
+
expect(markup).toContain('type="search"')
|
|
85
|
+
expect(markup).toContain('cv-p-search')
|
|
86
|
+
expect(markup).toContain(LABELS.searchLabel)
|
|
87
|
+
expect(markup).toContain(`placeholder="${LABELS.searchPlaceholder}"`)
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it('is not drawn when hidden or when the prop is absent', () => {
|
|
91
|
+
expect(renderInbox([WITH_PROTOCOL], { search: { value: '', isVisible: false } })).not.toContain('cv-p-search')
|
|
92
|
+
expect(renderInbox([WITH_PROTOCOL])).not.toContain('cv-p-search')
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
it('shows the no-results state instead of the empty inbox when a query finds nothing', () => {
|
|
96
|
+
const markup = renderInbox([], { search: { value: 'zzz', isVisible: true } })
|
|
97
|
+
expect(markup).toContain(LABELS.noResults)
|
|
98
|
+
expect(markup).not.toContain(LABELS.emptyInbox)
|
|
99
|
+
})
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
describe('protocol in the thread header', () => {
|
|
103
|
+
it('shows the protocol and a copy button of at least a touch target', () => {
|
|
104
|
+
const markup = renderThread(WITH_PROTOCOL)
|
|
105
|
+
expect(markup).toContain('261009-K7M2')
|
|
106
|
+
expect(markup).toContain('cv-p-protocol__copy')
|
|
107
|
+
expect(markup).toContain('type="button"')
|
|
108
|
+
expect(markup).toContain(LABELS.copyProtocol)
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
it('names the code with a visually hidden prefix and no aria-label on the generic span', () => {
|
|
112
|
+
const markup = renderThread(WITH_PROTOCOL)
|
|
113
|
+
expect(markup).toContain(`<span class="cv-p-sr-only">${LABELS.protocolPrefix} </span>261009-K7M2`)
|
|
114
|
+
expect(markup).not.toMatch(/<span[^>]*aria-label/)
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
it('keeps an empty polite live region in the tree', () => {
|
|
118
|
+
const markup = renderThread(WITH_PROTOCOL)
|
|
119
|
+
expect(markup).toMatch(/<div class="cv-p-sr-only" role="status" aria-live="polite"><\/div>/)
|
|
120
|
+
expect(markup).not.toContain(LABELS.protocolCopied)
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
it('draws neither protocol nor button nor live region when absent', () => {
|
|
124
|
+
const markup = renderThread(WITHOUT_PROTOCOL)
|
|
125
|
+
expect(markup).not.toContain('cv-p-protocol')
|
|
126
|
+
expect(markup.match(/aria-live="polite"/g)?.length).toBe(1)
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
it('does not nest a status region inside the protocol block', () => {
|
|
130
|
+
const markup = renderThread(WITH_PROTOCOL)
|
|
131
|
+
const block = markup.slice(
|
|
132
|
+
markup.indexOf('cv-p-protocol--header'),
|
|
133
|
+
markup.indexOf('</button>', markup.indexOf('cv-p-protocol__copy')),
|
|
134
|
+
)
|
|
135
|
+
expect(block).not.toContain('role="status"')
|
|
136
|
+
expect(block).not.toContain('aria-live')
|
|
137
|
+
})
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
describe('copy button', () => {
|
|
141
|
+
const render = (isCopied: boolean) =>
|
|
142
|
+
renderToStaticMarkup(
|
|
143
|
+
<ParticipantProtocolBadgeView
|
|
144
|
+
protocol="261009-K7M2"
|
|
145
|
+
labels={LABELS}
|
|
146
|
+
copyState={{ copyCount: isCopied ? 1 : 0, isCopied }}
|
|
147
|
+
onCopy={() => undefined}
|
|
148
|
+
/>,
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
it('has a default visible label starting the accessible name', () => {
|
|
152
|
+
expect(LABELS.copyProtocol).toBe('Copy protocol')
|
|
153
|
+
const markup = render(false)
|
|
154
|
+
expect(markup).toContain('<span aria-hidden="true">Copy protocol</span>')
|
|
155
|
+
expect(markup).toContain('<span class="cv-p-sr-only">Copy protocol 261009-K7M2</span>')
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
it('swaps the visible text for the confirmation while copied, hidden from assistive tech', () => {
|
|
159
|
+
const markup = render(true)
|
|
160
|
+
expect(markup).toContain(`<span aria-hidden="true">${LABELS.protocolCopied}</span>`)
|
|
161
|
+
expect(markup).toContain(`<span>${LABELS.protocolCopied}</span></div>`)
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
it('announces only through the live region, keyed by the copy count', () => {
|
|
165
|
+
expect(render(false)).toMatch(/aria-live="polite"><\/div>/)
|
|
166
|
+
expect(render(true)).toMatch(/aria-live="polite"><span>Protocol copied<\/span><\/div>/)
|
|
167
|
+
})
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
describe('inbox empty states', () => {
|
|
171
|
+
const search = { value: 'zzz', isVisible: true }
|
|
172
|
+
|
|
173
|
+
it('says the filter is the reason when the query matches in another subject', () => {
|
|
174
|
+
const markup = renderInbox([], { search, hasMatchesOutsideFilter: true })
|
|
175
|
+
expect(markup).toContain(LABELS.noResultsInFilter)
|
|
176
|
+
expect(markup).not.toContain(`>${LABELS.noResults}<`)
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
it('says the search covers only the loaded conversations when more exist', () => {
|
|
180
|
+
const markup = renderInbox([], { search, hasMore: true })
|
|
181
|
+
expect(markup).toContain(LABELS.noResultsLoadedOnly)
|
|
182
|
+
expect(markup).toContain(LABELS.loadMore)
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
it('keeps the plain message when nothing more can be loaded', () => {
|
|
186
|
+
expect(renderInbox([], { search })).toContain(`>${LABELS.noResults}<`)
|
|
187
|
+
})
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
describe('protocol markup hygiene', () => {
|
|
191
|
+
it('uses only cv-p-* classes and no Tailwind utilities', () => {
|
|
192
|
+
const markup =
|
|
193
|
+
renderInbox([WITH_PROTOCOL], { search: { value: '', isVisible: true } }) + renderThread(WITH_PROTOCOL)
|
|
194
|
+
expect(findUtilityClassTokens(markup)).toEqual([])
|
|
195
|
+
expect(findForeignClassTokens(markup)).toEqual([])
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
it('keeps product vocabulary out of the new sources and labels', () => {
|
|
199
|
+
const files = readdirSync(fileURLToPath(new URL('.', import.meta.url))).filter(
|
|
200
|
+
(file) => /\.tsx?$/.test(file) && !/\.test(-helper)?\.tsx?$/.test(file),
|
|
201
|
+
)
|
|
202
|
+
const source = files.map((file) => readFileSync(new URL(file, import.meta.url), 'utf8')).join('\n')
|
|
203
|
+
expect(files.length).toBeGreaterThan(20)
|
|
204
|
+
expect(source).not.toMatch(
|
|
205
|
+
/\b(carga|entrega|frete|cte|nf-?e|transport\w*|nota|ocorr\w*|motorista|viagem|trip|driver)\b/i,
|
|
206
|
+
)
|
|
207
|
+
})
|
|
208
|
+
})
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test'
|
|
2
|
+
import { renderToStaticMarkup } from 'react-dom/server'
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
buildConversation,
|
|
6
|
+
buildMessage,
|
|
7
|
+
findForeignClassTokens,
|
|
8
|
+
findUtilityClassTokens,
|
|
9
|
+
} from './participantFixtures.test-helper'
|
|
10
|
+
import { DEFAULT_PARTICIPANT_CONVERSATIONS_LABELS } from './participantLabels'
|
|
11
|
+
import type { ParticipantTimelineItem } from './participantMessages'
|
|
12
|
+
import { ParticipantThread, type ParticipantThreadProps } from './ParticipantThread'
|
|
13
|
+
|
|
14
|
+
const ITEMS: readonly ParticipantTimelineItem[] = [
|
|
15
|
+
{ kind: 'server', message: buildMessage({ id: 'a', createdAt: '2026-10-01T10:00:00.000Z' }) },
|
|
16
|
+
{ kind: 'server', message: buildMessage({ id: 'b', direction: 'inbound', createdAt: '2026-10-01T10:05:00.000Z' }) },
|
|
17
|
+
{ kind: 'server', message: buildMessage({ id: 'c', createdAt: '2026-10-02T09:00:00.000Z' }) },
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
function render(overrides: Partial<ParticipantThreadProps> = {}): string {
|
|
21
|
+
return renderToStaticMarkup(
|
|
22
|
+
<ParticipantThread
|
|
23
|
+
conversation={buildConversation({ subjectId: '1', subjectLabel: 'Invoice 4521' })}
|
|
24
|
+
items={ITEMS}
|
|
25
|
+
hasMore={false}
|
|
26
|
+
labels={DEFAULT_PARTICIPANT_CONVERSATIONS_LABELS}
|
|
27
|
+
resolveAttachmentUrl={async () => 'https://files.example/a'}
|
|
28
|
+
draft={{ value: '', onChange: () => undefined, files: [], onFilesChange: () => undefined }}
|
|
29
|
+
onSend={() => undefined}
|
|
30
|
+
status="ready"
|
|
31
|
+
refresh={() => undefined}
|
|
32
|
+
scroll={{ ref: { current: null }, onScroll: () => undefined }}
|
|
33
|
+
newMessagesCount={0}
|
|
34
|
+
isSending={false}
|
|
35
|
+
{...overrides}
|
|
36
|
+
/>,
|
|
37
|
+
)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
describe('ParticipantThread', () => {
|
|
41
|
+
it('renders the subject label as the title', () => {
|
|
42
|
+
expect(render()).toContain('Invoice 4521')
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('renders the back button only with onBack', () => {
|
|
46
|
+
expect(render()).not.toContain('cv-p-thread__back')
|
|
47
|
+
expect(render({ onBack: () => undefined })).toContain('cv-p-thread__back')
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('makes the title clickable only with onOpenSubject', () => {
|
|
51
|
+
expect(render()).not.toContain('cv-p-thread__title-button')
|
|
52
|
+
expect(render({ onOpenSubject: () => undefined })).toContain('cv-p-thread__title-button')
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('draws nothing for the subject card slot when renderSubjectCard is absent', () => {
|
|
56
|
+
expect(render()).not.toContain('cv-p-thread__subject-card')
|
|
57
|
+
const markup = render({ renderSubjectCard: () => <p>Card body</p> })
|
|
58
|
+
expect(markup).toContain('cv-p-thread__subject-card')
|
|
59
|
+
expect(markup).toContain('Card body')
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('shows channel badges in the header only with channels', () => {
|
|
63
|
+
expect(render()).not.toContain('cv-p-channels')
|
|
64
|
+
const markup = render({
|
|
65
|
+
conversation: buildConversation({ subjectId: '1', channels: ['email', 'app'] }),
|
|
66
|
+
})
|
|
67
|
+
expect(markup).toContain('cv-p-channel--app')
|
|
68
|
+
expect(markup.indexOf('cv-p-channel--app')).toBeLessThan(markup.indexOf('cv-p-channel--email'))
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it('renders day dividers between days', () => {
|
|
72
|
+
expect(render().match(/cv-p-day"/g)?.length).toBe(2)
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('has a polite live region that announces new messages', () => {
|
|
76
|
+
const quiet = render()
|
|
77
|
+
const loud = render({ newMessagesCount: 2 })
|
|
78
|
+
|
|
79
|
+
expect(quiet).toContain('aria-live="polite"')
|
|
80
|
+
expect(quiet).not.toContain('New messages')
|
|
81
|
+
expect(loud).toContain('aria-live="polite"')
|
|
82
|
+
expect(loud).toContain('New messages')
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it('while loading shows a busy skeleton', () => {
|
|
86
|
+
const markup = render({ items: [], status: 'loading' })
|
|
87
|
+
|
|
88
|
+
expect(markup).toContain('aria-busy="true"')
|
|
89
|
+
expect(markup).toContain('Loading…')
|
|
90
|
+
expect(render()).toContain('aria-busy="false"')
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
it('on error shows an alert with retry', () => {
|
|
94
|
+
const markup = render({ items: [], status: 'error' })
|
|
95
|
+
|
|
96
|
+
expect(markup).toContain('role="alert"')
|
|
97
|
+
expect(markup).toContain('Retry')
|
|
98
|
+
expect(markup).not.toContain('aria-busy="true"')
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
it('on success draws neither loading nor error', () => {
|
|
102
|
+
const markup = render()
|
|
103
|
+
|
|
104
|
+
expect(markup).not.toContain('role="alert"')
|
|
105
|
+
expect(markup).not.toContain('cv-p-loading')
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
it('disables only the send button while sending, never the field', () => {
|
|
109
|
+
const markup = render({
|
|
110
|
+
isSending: true,
|
|
111
|
+
draft: { value: 'hi', onChange: () => undefined, files: [], onFilesChange: () => undefined },
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
expect(markup).toMatch(/<button[^>]*type="submit"[^>]*disabled/)
|
|
115
|
+
expect(markup).not.toMatch(/<textarea[^>]*disabled/)
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
it('shows load older only when hasMore and onLoadOlder exist', () => {
|
|
119
|
+
expect(render()).not.toContain('Load older messages')
|
|
120
|
+
expect(render({ hasMore: true, onLoadOlder: () => undefined })).toContain('Load older messages')
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
it('shows the closed notice and no composer when the conversation is closed', () => {
|
|
124
|
+
const markup = render({ conversation: buildConversation({ subjectId: '1', status: 'closed' }) })
|
|
125
|
+
|
|
126
|
+
expect(markup).toContain('This conversation is closed')
|
|
127
|
+
expect(markup).not.toContain('<form')
|
|
128
|
+
expect(markup).not.toContain('<textarea')
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
it('shows the composer when the conversation is open', () => {
|
|
132
|
+
const markup = render()
|
|
133
|
+
|
|
134
|
+
expect(markup).toContain('<form')
|
|
135
|
+
expect(markup).not.toContain('This conversation is closed')
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
it('renders quick replies only when given', () => {
|
|
139
|
+
expect(render()).not.toContain('cv-p-quick')
|
|
140
|
+
expect(render({ quickReplies: [{ id: '1', title: 'Hi', shortcut: 'hi', body: 'Hi' }] })).toContain('cv-p-quick')
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
it('uses only cv-p-* classes and no Tailwind utilities', () => {
|
|
144
|
+
const markup = render({ onBack: () => undefined, hasMore: true, onLoadOlder: () => undefined })
|
|
145
|
+
|
|
146
|
+
expect(findUtilityClassTokens(markup)).toEqual([])
|
|
147
|
+
expect(findForeignClassTokens(markup)).toEqual([])
|
|
148
|
+
})
|
|
149
|
+
})
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import type { ReactNode } from 'react'
|
|
2
|
+
|
|
3
|
+
import type { ParticipantConversationSummary, ParticipantSubjectRef } from '@adatechnology/conversation-contracts'
|
|
4
|
+
|
|
5
|
+
import { channelCapabilityFor } from '../channelCapability'
|
|
6
|
+
import type { ConversationChannel } from '../conversationChannel'
|
|
7
|
+
import { isSameDay } from '../lib/format'
|
|
8
|
+
import type { QuickReply } from '../quickReplies/quickReply.types'
|
|
9
|
+
import { ParticipantComposer } from './ParticipantComposer'
|
|
10
|
+
import type { ResolveParticipantAttachmentUrl } from './ParticipantAttachmentItem'
|
|
11
|
+
import {
|
|
12
|
+
bindPendingActions,
|
|
13
|
+
resolveParticipantBubbleActions,
|
|
14
|
+
type ParticipantPendingActions,
|
|
15
|
+
} from './participantBubbleActions'
|
|
16
|
+
import type { ParticipantConversationsLabels } from './participantLabels'
|
|
17
|
+
import { resolveLoadView, type ParticipantLoadStatus } from './participantLoadView'
|
|
18
|
+
import { ParticipantLoadError, ParticipantLoading } from './ParticipantLoadState'
|
|
19
|
+
import type { ParticipantThreadScroll } from './useStickToBottom'
|
|
20
|
+
import type { ParticipantTimelineItem } from './participantMessages'
|
|
21
|
+
import { ParticipantMessageBubble } from './ParticipantMessageBubble'
|
|
22
|
+
import { ParticipantChannelBadges } from './ParticipantChannelBadges'
|
|
23
|
+
import { ParticipantProtocolBadge } from './ParticipantProtocolBadge'
|
|
24
|
+
|
|
25
|
+
export type ParticipantThreadDraft = {
|
|
26
|
+
readonly value: string
|
|
27
|
+
readonly onChange: (value: string) => void
|
|
28
|
+
readonly files: readonly File[]
|
|
29
|
+
readonly onFilesChange: (files: readonly File[]) => void
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export type ParticipantThreadProps = {
|
|
33
|
+
readonly conversation: ParticipantConversationSummary
|
|
34
|
+
readonly items: readonly ParticipantTimelineItem[]
|
|
35
|
+
readonly hasMore: boolean
|
|
36
|
+
readonly labels: ParticipantConversationsLabels
|
|
37
|
+
readonly resolveAttachmentUrl: ResolveParticipantAttachmentUrl
|
|
38
|
+
readonly draft: ParticipantThreadDraft
|
|
39
|
+
readonly onSend: () => void
|
|
40
|
+
readonly status: ParticipantLoadStatus
|
|
41
|
+
readonly refresh: () => void
|
|
42
|
+
/** Ref and onScroll of the scrolling area; the hook that owns the rule is useParticipantThreadScroll. */
|
|
43
|
+
readonly scroll: ParticipantThreadScroll
|
|
44
|
+
/** Messages from the other side that arrived while the user was reading above. */
|
|
45
|
+
readonly newMessagesCount: number
|
|
46
|
+
/** A send is in flight: only the send button waits, the field stays editable. */
|
|
47
|
+
readonly isSending: boolean
|
|
48
|
+
readonly channel?: ConversationChannel
|
|
49
|
+
readonly locale?: string
|
|
50
|
+
readonly onBack?: () => void
|
|
51
|
+
readonly onOpenSubject?: (subject: ParticipantSubjectRef) => void
|
|
52
|
+
readonly onLoadOlder?: () => void
|
|
53
|
+
readonly pendingActions?: ParticipantPendingActions
|
|
54
|
+
readonly quickReplies?: readonly QuickReply[]
|
|
55
|
+
readonly acceptedTypes?: readonly string[]
|
|
56
|
+
readonly maxLength?: number
|
|
57
|
+
readonly renderSubjectCard?: (conversation: ParticipantConversationSummary) => ReactNode
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function createdAtOf(item: ParticipantTimelineItem): string {
|
|
61
|
+
return item.kind === 'server' ? item.message.createdAt : item.pending.createdAt
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function keyOf(item: ParticipantTimelineItem): string {
|
|
65
|
+
return item.kind === 'server' ? item.message.id : `pending:${item.pending.clientMessageId}`
|
|
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>
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function Footer({ props }: { readonly props: ParticipantThreadProps }) {
|
|
138
|
+
const { conversation, labels, draft } = props
|
|
139
|
+
if (conversation.status === 'closed') {
|
|
140
|
+
return <p className="cv-p-thread__closed">{labels.closedNotice}</p>
|
|
141
|
+
}
|
|
142
|
+
return (
|
|
143
|
+
<ParticipantComposer
|
|
144
|
+
value={draft.value}
|
|
145
|
+
onChange={draft.onChange}
|
|
146
|
+
files={draft.files}
|
|
147
|
+
onFilesChange={draft.onFilesChange}
|
|
148
|
+
onSend={props.onSend}
|
|
149
|
+
labels={labels}
|
|
150
|
+
channel={props.channel}
|
|
151
|
+
isSending={props.isSending}
|
|
152
|
+
maxLength={props.maxLength}
|
|
153
|
+
acceptedTypes={props.acceptedTypes}
|
|
154
|
+
quickReplies={props.quickReplies}
|
|
155
|
+
/>
|
|
156
|
+
)
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export function ParticipantThread(props: ParticipantThreadProps) {
|
|
160
|
+
const { conversation, labels, hasMore, onLoadOlder, renderSubjectCard } = props
|
|
161
|
+
const subjectCard = renderSubjectCard?.(conversation)
|
|
162
|
+
const loadView = resolveLoadView({ status: props.status, hasItems: props.items.length > 0 })
|
|
163
|
+
|
|
164
|
+
return (
|
|
165
|
+
<div className="cv-p cv-p-thread" aria-busy={loadView.isLoading}>
|
|
166
|
+
<Header props={props} />
|
|
167
|
+
{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}>
|
|
169
|
+
{loadView.isLoading ? <ParticipantLoading labels={labels} /> : null}
|
|
170
|
+
{loadView.hasError ? <ParticipantLoadError labels={labels} onRetry={props.refresh} /> : null}
|
|
171
|
+
{hasMore && onLoadOlder ? (
|
|
172
|
+
<button type="button" className="cv-p-thread__older" onClick={onLoadOlder}>
|
|
173
|
+
{labels.loadOlder}
|
|
174
|
+
</button>
|
|
175
|
+
) : null}
|
|
176
|
+
<Timeline props={props} />
|
|
177
|
+
</div>
|
|
178
|
+
<div className="cv-p-thread__live" role="status" aria-live="polite">
|
|
179
|
+
{props.newMessagesCount > 0 ? labels.newMessages : ''}
|
|
180
|
+
</div>
|
|
181
|
+
<Footer props={props} />
|
|
182
|
+
</div>
|
|
183
|
+
)
|
|
184
|
+
}
|