@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.
Files changed (110) hide show
  1. package/README.md +231 -0
  2. package/dist/{ConversationSimulatorPanel-DnXElfY8.d.ts → ConversationSimulatorPanel-CrfFjiWh.d.ts} +2 -268
  3. package/dist/{chunk-PJNR6UMN.js → chunk-CBYEHPX6.js} +1 -63
  4. package/dist/{chunk-4P5KMWOI.js → chunk-FAQLC3TX.js} +327 -522
  5. package/dist/{chunk-HMCOTZAX.js → chunk-HOKNKXIT.js} +1 -1
  6. package/dist/chunk-MRVFFJEH.js +64 -0
  7. package/dist/chunk-O6457XHK.js +255 -0
  8. package/dist/chunk-OVFCLRQQ.js +84 -0
  9. package/dist/flows/index.js +5 -3
  10. package/dist/index.d.ts +21 -5
  11. package/dist/index.js +783 -807
  12. package/dist/participant/index.d.ts +186 -0
  13. package/dist/participant/index.js +1708 -0
  14. package/dist/preview/index.d.ts +4 -2
  15. package/dist/preview/index.js +4 -2
  16. package/dist/quickReply.types-B4gmZsSr.d.ts +274 -0
  17. package/dist/styles.css +689 -0
  18. package/package.json +8 -4
  19. package/src/ChannelIcon.tsx +2 -0
  20. package/src/MessageText.test.tsx +76 -0
  21. package/src/MessageText.tsx +28 -12
  22. package/src/MessageTextCopiedBadge.tsx +15 -0
  23. package/src/StatusTicks.test.tsx +51 -0
  24. package/src/StatusTicks.tsx +36 -6
  25. package/src/buildOutput.test.ts +41 -0
  26. package/src/channelCapability.test.ts +17 -0
  27. package/src/conversationChannel.test.ts +30 -1
  28. package/src/conversationChannel.ts +19 -1
  29. package/src/legacyChannels.golden.test.ts +89 -0
  30. package/src/legacyDefaults.golden.test.tsx +127 -0
  31. package/src/legacyStyles.fixture.css +1121 -0
  32. package/src/legacyStyles.golden.test.ts +121 -0
  33. package/src/messageTextCopyInteraction.test.ts +75 -0
  34. package/src/messageTextCopyInteraction.ts +37 -0
  35. package/src/participant/ParticipantAttachmentItem.tsx +77 -0
  36. package/src/participant/ParticipantChannelBadges.test.tsx +59 -0
  37. package/src/participant/ParticipantChannelBadges.tsx +75 -0
  38. package/src/participant/ParticipantComposer.test.tsx +97 -0
  39. package/src/participant/ParticipantComposer.tsx +167 -0
  40. package/src/participant/ParticipantConversations.test.tsx +90 -0
  41. package/src/participant/ParticipantConversations.tsx +49 -0
  42. package/src/participant/ParticipantConversationsScreen.tsx +78 -0
  43. package/src/participant/ParticipantInbox.test.tsx +244 -0
  44. package/src/participant/ParticipantInbox.tsx +149 -0
  45. package/src/participant/ParticipantInboxRow.tsx +75 -0
  46. package/src/participant/ParticipantInboxSearchField.tsx +34 -0
  47. package/src/participant/ParticipantLoadState.tsx +27 -0
  48. package/src/participant/ParticipantMessageBubble.test.tsx +167 -0
  49. package/src/participant/ParticipantMessageBubble.tsx +185 -0
  50. package/src/participant/ParticipantProtocolBadge.tsx +74 -0
  51. package/src/participant/ParticipantProtocolView.test.tsx +208 -0
  52. package/src/participant/ParticipantThread.test.tsx +149 -0
  53. package/src/participant/ParticipantThread.tsx +184 -0
  54. package/src/participant/ParticipantThreadScreen.tsx +135 -0
  55. package/src/participant/bindAttachmentUrlResolver.test.ts +25 -0
  56. package/src/participant/bindAttachmentUrlResolver.ts +7 -0
  57. package/src/participant/index.ts +26 -0
  58. package/src/participant/participant.types.ts +52 -0
  59. package/src/participant/participantApi.types.ts +59 -0
  60. package/src/participant/participantAttachmentUrl.test.ts +32 -0
  61. package/src/participant/participantAttachmentUrl.ts +10 -0
  62. package/src/participant/participantBubbleActions.test.ts +44 -0
  63. package/src/participant/participantBubbleActions.ts +40 -0
  64. package/src/participant/participantChannels.test.ts +42 -0
  65. package/src/participant/participantChannels.ts +33 -0
  66. package/src/participant/participantDrafts.test.ts +65 -0
  67. package/src/participant/participantDrafts.ts +50 -0
  68. package/src/participant/participantFileValidation.test.ts +86 -0
  69. package/src/participant/participantFileValidation.ts +72 -0
  70. package/src/participant/participantFixtures.test-helper.ts +41 -0
  71. package/src/participant/participantGrouping.test.ts +236 -0
  72. package/src/participant/participantGrouping.ts +90 -0
  73. package/src/participant/participantInboxState.test.ts +69 -0
  74. package/src/participant/participantInboxState.ts +74 -0
  75. package/src/participant/participantLabels.ts +115 -0
  76. package/src/participant/participantLoadView.test.ts +23 -0
  77. package/src/participant/participantLoadView.ts +21 -0
  78. package/src/participant/participantMessages.test.ts +128 -0
  79. package/src/participant/participantMessages.ts +73 -0
  80. package/src/participant/participantNewMessages.test.ts +54 -0
  81. package/src/participant/participantNewMessages.ts +29 -0
  82. package/src/participant/participantProtocol.test.ts +104 -0
  83. package/src/participant/participantProtocol.ts +52 -0
  84. package/src/participant/participantProtocolCopy.test.ts +45 -0
  85. package/src/participant/participantProtocolCopy.ts +32 -0
  86. package/src/participant/participantQuickReply.test.ts +23 -0
  87. package/src/participant/participantQuickReply.ts +6 -0
  88. package/src/participant/participantScroll.test.ts +69 -0
  89. package/src/participant/participantScroll.ts +43 -0
  90. package/src/participant/participantSendController.test.ts +201 -0
  91. package/src/participant/participantSendController.ts +134 -0
  92. package/src/participant/participantSendStates.ts +31 -0
  93. package/src/participant/participantTheme.ts +20 -0
  94. package/src/participant/participantThreadState.test.ts +75 -0
  95. package/src/participant/participantThreadState.ts +59 -0
  96. package/src/participant/shouldMarkRead.test.ts +24 -0
  97. package/src/participant/shouldMarkRead.ts +16 -0
  98. package/src/participant/useDocumentVisibility.ts +18 -0
  99. package/src/participant/useParticipantConversationView.ts +60 -0
  100. package/src/participant/useParticipantDraft.ts +45 -0
  101. package/src/participant/useParticipantInbox.ts +57 -0
  102. package/src/participant/useParticipantMarkRead.ts +26 -0
  103. package/src/participant/useParticipantRevalidation.ts +36 -0
  104. package/src/participant/useParticipantSend.ts +84 -0
  105. package/src/participant/useParticipantThread.ts +116 -0
  106. package/src/participant/useParticipantThreadScroll.ts +26 -0
  107. package/src/participant/useStickToBottom.ts +77 -0
  108. package/src/preview/ConversationSimulatorPanel.tsx +8 -0
  109. package/src/styles.css +178 -0
  110. 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
+ }