@adatechnology/conversations-ui 0.4.1 → 0.5.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 (93) hide show
  1. package/README.md +169 -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-HMCOTZAX.js → chunk-HOKNKXIT.js} +1 -1
  5. package/dist/{chunk-4P5KMWOI.js → chunk-JXJCPN34.js} +327 -522
  6. package/dist/chunk-MRVFFJEH.js +64 -0
  7. package/dist/chunk-RJMSM3YA.js +249 -0
  8. package/dist/chunk-WSFGNR5R.js +55 -0
  9. package/dist/flows/index.js +5 -3
  10. package/dist/index.d.ts +9 -3
  11. package/dist/index.js +783 -807
  12. package/dist/participant/index.d.ts +162 -0
  13. package/dist/participant/index.js +1448 -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 +591 -0
  18. package/package.json +9 -5
  19. package/src/ChannelIcon.tsx +2 -0
  20. package/src/MessageBubble.test.tsx +2 -2
  21. package/src/MessageText.test.tsx +59 -0
  22. package/src/MessageText.tsx +20 -12
  23. package/src/MessageTextCopiedBadge.tsx +7 -0
  24. package/src/StatusTicks.test.tsx +30 -0
  25. package/src/StatusTicks.tsx +24 -9
  26. package/src/buildOutput.test.ts +41 -0
  27. package/src/channelCapability.test.ts +17 -0
  28. package/src/conversationChannel.test.ts +30 -1
  29. package/src/conversationChannel.ts +19 -1
  30. package/src/participant/ParticipantAttachmentItem.tsx +77 -0
  31. package/src/participant/ParticipantComposer.test.tsx +97 -0
  32. package/src/participant/ParticipantComposer.tsx +167 -0
  33. package/src/participant/ParticipantConversations.test.tsx +90 -0
  34. package/src/participant/ParticipantConversations.tsx +42 -0
  35. package/src/participant/ParticipantConversationsScreen.tsx +67 -0
  36. package/src/participant/ParticipantInbox.test.tsx +147 -0
  37. package/src/participant/ParticipantInbox.tsx +168 -0
  38. package/src/participant/ParticipantLoadState.tsx +27 -0
  39. package/src/participant/ParticipantMessageBubble.test.tsx +165 -0
  40. package/src/participant/ParticipantMessageBubble.tsx +185 -0
  41. package/src/participant/ParticipantThread.test.tsx +132 -0
  42. package/src/participant/ParticipantThread.tsx +174 -0
  43. package/src/participant/ParticipantThreadScreen.tsx +135 -0
  44. package/src/participant/bindAttachmentUrlResolver.test.ts +25 -0
  45. package/src/participant/bindAttachmentUrlResolver.ts +7 -0
  46. package/src/participant/index.ts +25 -0
  47. package/src/participant/participant.types.ts +43 -0
  48. package/src/participant/participantApi.types.ts +59 -0
  49. package/src/participant/participantAttachmentUrl.test.ts +32 -0
  50. package/src/participant/participantAttachmentUrl.ts +10 -0
  51. package/src/participant/participantBubbleActions.test.ts +44 -0
  52. package/src/participant/participantBubbleActions.ts +40 -0
  53. package/src/participant/participantDrafts.test.ts +65 -0
  54. package/src/participant/participantDrafts.ts +50 -0
  55. package/src/participant/participantFileValidation.test.ts +86 -0
  56. package/src/participant/participantFileValidation.ts +72 -0
  57. package/src/participant/participantFixtures.test-helper.ts +41 -0
  58. package/src/participant/participantGrouping.test.ts +138 -0
  59. package/src/participant/participantGrouping.ts +77 -0
  60. package/src/participant/participantInboxState.test.ts +69 -0
  61. package/src/participant/participantInboxState.ts +74 -0
  62. package/src/participant/participantLabels.ts +86 -0
  63. package/src/participant/participantLoadView.test.ts +23 -0
  64. package/src/participant/participantLoadView.ts +21 -0
  65. package/src/participant/participantMessages.test.ts +128 -0
  66. package/src/participant/participantMessages.ts +73 -0
  67. package/src/participant/participantNewMessages.test.ts +54 -0
  68. package/src/participant/participantNewMessages.ts +29 -0
  69. package/src/participant/participantQuickReply.test.ts +23 -0
  70. package/src/participant/participantQuickReply.ts +6 -0
  71. package/src/participant/participantScroll.test.ts +69 -0
  72. package/src/participant/participantScroll.ts +43 -0
  73. package/src/participant/participantSendController.test.ts +201 -0
  74. package/src/participant/participantSendController.ts +134 -0
  75. package/src/participant/participantSendStates.ts +31 -0
  76. package/src/participant/participantTheme.ts +20 -0
  77. package/src/participant/participantThreadState.test.ts +75 -0
  78. package/src/participant/participantThreadState.ts +59 -0
  79. package/src/participant/shouldMarkRead.test.ts +24 -0
  80. package/src/participant/shouldMarkRead.ts +16 -0
  81. package/src/participant/useDocumentVisibility.ts +18 -0
  82. package/src/participant/useParticipantConversationView.ts +60 -0
  83. package/src/participant/useParticipantDraft.ts +45 -0
  84. package/src/participant/useParticipantInbox.ts +57 -0
  85. package/src/participant/useParticipantMarkRead.ts +26 -0
  86. package/src/participant/useParticipantRevalidation.ts +36 -0
  87. package/src/participant/useParticipantSend.ts +84 -0
  88. package/src/participant/useParticipantThread.ts +116 -0
  89. package/src/participant/useParticipantThreadScroll.ts +26 -0
  90. package/src/participant/useStickToBottom.ts +77 -0
  91. package/src/preview/ConversationSimulatorPanel.tsx +8 -0
  92. package/src/styles.css +160 -0
  93. package/src/types.ts +3 -1
@@ -0,0 +1,165 @@
1
+ import { describe, expect, it } from 'bun:test'
2
+ import { renderToStaticMarkup } from 'react-dom/server'
3
+
4
+ import { DEFAULT_PARTICIPANT_CONVERSATIONS_LABELS } from './participantLabels'
5
+ import { findForeignClassTokens, findUtilityClassTokens, buildMessage } from './participantFixtures.test-helper'
6
+ import type { ParticipantTimelineItem } from './participantMessages'
7
+ import { ParticipantMessageBubble } from './ParticipantMessageBubble'
8
+
9
+ const LABELS = DEFAULT_PARTICIPANT_CONVERSATIONS_LABELS
10
+ const resolveAttachmentUrl = async (): Promise<string> => 'https://files.example/a.png'
11
+
12
+ function renderServer(message = buildMessage({ id: 'm1' }), extra: { onRetry?: () => void; confirmsRead?: boolean } = {}): string {
13
+ const item: ParticipantTimelineItem = { kind: 'server', message }
14
+ return renderToStaticMarkup(
15
+ <ParticipantMessageBubble
16
+ item={item}
17
+ labels={LABELS}
18
+ confirmsRead={extra.confirmsRead ?? true}
19
+ resolveAttachmentUrl={resolveAttachmentUrl}
20
+ onRetry={extra.onRetry}
21
+ />,
22
+ )
23
+ }
24
+
25
+ function renderPending(
26
+ displayState: 'sending' | 'queued' | 'failed',
27
+ onRetry?: () => void,
28
+ extra: { onDiscard?: () => void; onEdit?: () => void } = {},
29
+ ): string {
30
+ const item: ParticipantTimelineItem = {
31
+ kind: 'pending',
32
+ origin: 'host',
33
+ displayState,
34
+ pending: {
35
+ clientMessageId: 'c1',
36
+ subject: { subjectType: 'invoice', subjectId: '1' },
37
+ text: 'Draft text',
38
+ createdAt: '2026-10-01T10:00:00.000Z',
39
+ state: displayState === 'sending' ? 'failed' : displayState,
40
+ },
41
+ }
42
+ return renderToStaticMarkup(
43
+ <ParticipantMessageBubble
44
+ item={item}
45
+ labels={LABELS}
46
+ confirmsRead
47
+ resolveAttachmentUrl={resolveAttachmentUrl}
48
+ onRetry={onRetry}
49
+ onDiscard={extra.onDiscard}
50
+ onEdit={extra.onEdit}
51
+ />,
52
+ )
53
+ }
54
+
55
+ describe('ParticipantMessageBubble', () => {
56
+ it('puts an inbound message on the own side and an outbound one on the other', () => {
57
+ expect(renderServer(buildMessage({ id: 'a', direction: 'inbound' }))).toContain('cv-p-bubble--mine')
58
+ expect(renderServer(buildMessage({ id: 'b', direction: 'outbound' }))).not.toContain('cv-p-bubble--mine')
59
+ })
60
+
61
+ it('shows the author name only when the message is not mine', () => {
62
+ const theirs = renderServer(buildMessage({ id: 'a', direction: 'outbound', authorName: 'Marina' }))
63
+ const mine = renderServer(buildMessage({ id: 'b', direction: 'inbound', authorName: 'Marina' }))
64
+
65
+ expect(theirs).toContain('Marina')
66
+ expect(mine).not.toContain('Marina')
67
+ })
68
+
69
+ it('renders a time element with the ISO dateTime', () => {
70
+ expect(renderServer()).toMatch(/<time[^>]*dateTime="2026-10-01T10:00:00.000Z"/)
71
+ })
72
+
73
+ it.each([
74
+ ['sent', 'cv-status-ticks--sent', 'Sent'],
75
+ ['delivered', 'cv-status-ticks--delivered', 'Delivered'],
76
+ ['read', 'cv-status-ticks--read', 'Read'],
77
+ ] as const)('shows the %s status on my message with accessible text', (status, ticksClass, text) => {
78
+ const markup = renderServer(buildMessage({ id: 'a', direction: 'inbound', status }))
79
+
80
+ expect(markup).toContain(ticksClass)
81
+ expect(markup).toContain(text)
82
+ })
83
+
84
+ it('downgrades read to delivered when the channel does not confirm reads', () => {
85
+ const markup = renderServer(buildMessage({ id: 'a', direction: 'inbound', status: 'read' }), { confirmsRead: false })
86
+
87
+ expect(markup).toContain('cv-status-ticks--delivered')
88
+ expect(markup).not.toContain('cv-status-ticks--read')
89
+ })
90
+
91
+ it('shows no status on the other side', () => {
92
+ expect(renderServer(buildMessage({ id: 'a', direction: 'outbound', status: 'read' }))).not.toContain('cv-status-ticks')
93
+ })
94
+
95
+ it('renders pending sending and queued with their texts', () => {
96
+ expect(renderPending('sending')).toContain('Sending')
97
+ const queued = renderPending('queued')
98
+ expect(queued).toContain('Queued — sends when back online')
99
+ expect(queued).toContain('cv-status-ticks--queued')
100
+ })
101
+
102
+ it('renders the retry button only for failed with onRetry', () => {
103
+ const withRetry = renderPending('failed', () => undefined)
104
+ const withoutRetry = renderPending('failed')
105
+
106
+ expect(withRetry).toContain('<button')
107
+ expect(withRetry).toContain('Failed — tap to retry')
108
+ expect(withoutRetry).not.toContain('<button')
109
+ expect(withoutRetry).toContain('cv-status-ticks--failed')
110
+ })
111
+
112
+ it('shows discard and edit on a failed pending only when the handlers exist', () => {
113
+ const none = renderPending('failed')
114
+ const both = renderPending('failed', () => undefined, { onDiscard: () => undefined, onEdit: () => undefined })
115
+ const notFailed = renderPending('queued', undefined, { onDiscard: () => undefined, onEdit: () => undefined })
116
+
117
+ expect(none).not.toContain('cv-p-bubble__action')
118
+ expect(both).toContain('>Discard<')
119
+ expect(both).toContain('>Edit<')
120
+ expect(notFailed).not.toContain('cv-p-bubble__action')
121
+ })
122
+
123
+ it('a failed server message shows only "Failed", with no button', () => {
124
+ const markup = renderServer(buildMessage({ id: 'a', direction: 'inbound', status: 'failed' }))
125
+
126
+ expect(markup).toContain('Failed')
127
+ expect(markup).not.toContain('<button')
128
+ })
129
+
130
+ it('renders attachments by kind: audio controls, image alt, document link', () => {
131
+ const message = buildMessage({
132
+ id: 'a',
133
+ text: null,
134
+ attachments: [
135
+ { id: '1', kind: 'audio', filename: 'voice.ogg', mimeType: 'audio/ogg', sizeBytes: 10 },
136
+ { id: '2', kind: 'image', filename: 'photo.png', mimeType: 'image/png', sizeBytes: 10 },
137
+ { id: '3', kind: 'document', filename: 'note.pdf', mimeType: 'application/pdf', sizeBytes: 2048 },
138
+ ],
139
+ })
140
+ const markup = renderServer(message)
141
+
142
+ expect(markup).toContain('photo.png')
143
+ expect(markup).toContain('note.pdf')
144
+ expect(markup).toContain('voice.ogg')
145
+ expect(markup).toContain('cv-p-attachment--image')
146
+ expect(markup).toContain('cv-p-attachment--audio')
147
+ expect(markup).toContain('cv-p-attachment--document')
148
+ })
149
+
150
+ it('o texto do participante não é botão nem copia ao toque', () => {
151
+ const markup = renderServer(buildMessage({ id: 'a', direction: 'inbound' }))
152
+
153
+ expect(markup).toContain('class="cv-message-text"')
154
+ expect(markup).not.toContain('role="button"')
155
+ expect(markup).not.toContain('cv-message-text--copyable')
156
+ })
157
+
158
+ it('uses only cv-p-* classes and no Tailwind utilities', () => {
159
+ const markup = renderServer(buildMessage({ id: 'a', direction: 'inbound', status: 'read' }))
160
+
161
+ expect(findUtilityClassTokens(markup)).toEqual([])
162
+ expect(findForeignClassTokens(markup)).toEqual([])
163
+ expect(markup).toContain('cv-p-')
164
+ })
165
+ })
@@ -0,0 +1,185 @@
1
+ import type { MessageDeliveryStatus, ParticipantAttachment } from '@adatechnology/conversation-contracts'
2
+
3
+ import { MessageText } from '../MessageText'
4
+ import { StatusTicks } from '../StatusTicks'
5
+ import { formatTimestamp } from '../lib/format'
6
+ import { ParticipantAttachmentItem, type ResolveParticipantAttachmentUrl } from './ParticipantAttachmentItem'
7
+ import type { ParticipantConversationsLabels } from './participantLabels'
8
+ import {
9
+ isOwnMessage,
10
+ resolveOwnMessageStatus,
11
+ type ParticipantOwnMessageStatus,
12
+ type ParticipantPendingDisplayState,
13
+ type ParticipantTimelineItem,
14
+ } from './participantMessages'
15
+
16
+ export type ParticipantMessageBubbleProps = {
17
+ readonly item: ParticipantTimelineItem
18
+ readonly labels: ParticipantConversationsLabels
19
+ readonly confirmsRead: boolean
20
+ readonly resolveAttachmentUrl: ResolveParticipantAttachmentUrl
21
+ /** Absent means no retry button, even when the message failed. */
22
+ readonly onRetry?: () => void
23
+ /** Absent means no discard button. */
24
+ readonly onDiscard?: () => void
25
+ /** Absent means no edit button. */
26
+ readonly onEdit?: () => void
27
+ }
28
+
29
+ type BubbleContent = {
30
+ readonly isMine: boolean
31
+ readonly authorName?: string
32
+ readonly text?: string
33
+ readonly createdAt: string
34
+ readonly attachments: readonly ParticipantAttachment[]
35
+ readonly pendingFilenames: readonly string[]
36
+ readonly displayState?: ParticipantPendingDisplayState
37
+ readonly serverStatus?: MessageDeliveryStatus
38
+ }
39
+
40
+ function describeItem(item: ParticipantTimelineItem): BubbleContent {
41
+ if (item.kind === 'server') {
42
+ const { message } = item
43
+ return {
44
+ isMine: isOwnMessage(message),
45
+ authorName: message.authorName ?? undefined,
46
+ text: message.text ?? undefined,
47
+ createdAt: message.createdAt,
48
+ attachments: message.attachments,
49
+ pendingFilenames: [],
50
+ serverStatus: message.status,
51
+ }
52
+ }
53
+ return {
54
+ isMine: true,
55
+ text: item.pending.text,
56
+ createdAt: item.pending.createdAt,
57
+ attachments: [],
58
+ pendingFilenames: (item.pending.attachments ?? []).map((attachment) => attachment.filename),
59
+ displayState: item.displayState,
60
+ }
61
+ }
62
+
63
+ function statusText(status: ParticipantOwnMessageStatus, labels: ParticipantConversationsLabels): string {
64
+ const byStatus: Record<ParticipantOwnMessageStatus, string> = {
65
+ sending: labels.statusSending,
66
+ queued: labels.statusQueued,
67
+ sent: labels.statusSent,
68
+ delivered: labels.statusDelivered,
69
+ read: labels.statusRead,
70
+ failed: labels.statusFailed,
71
+ }
72
+ return byStatus[status]
73
+ }
74
+
75
+ type OwnStatusProps = {
76
+ readonly status: ParticipantOwnMessageStatus
77
+ readonly labels: ParticipantConversationsLabels
78
+ readonly onRetry?: () => void
79
+ }
80
+
81
+ function OwnStatus({ status, labels, onRetry }: OwnStatusProps) {
82
+ const ticksStatus = status === 'sending' ? 'queued' : status
83
+ const isVisibleText = status === 'sending' || status === 'queued' || status === 'failed'
84
+
85
+ if (status === 'failed' && onRetry) {
86
+ return (
87
+ <button type="button" className="cv-p-bubble__retry" onClick={onRetry}>
88
+ <StatusTicks status="failed" />
89
+ <span>{labels.statusFailed}</span>
90
+ </button>
91
+ )
92
+ }
93
+
94
+ const text = status === 'failed' ? labels.statusFailedShort : statusText(status, labels)
95
+ return (
96
+ <span className="cv-p-bubble__status">
97
+ <StatusTicks status={ticksStatus} />
98
+ <span className={isVisibleText ? 'cv-p-bubble__status-text' : 'cv-p-sr-only'}>{text}</span>
99
+ </span>
100
+ )
101
+ }
102
+
103
+ type FailedActionsProps = {
104
+ readonly labels: ParticipantConversationsLabels
105
+ readonly onDiscard?: () => void
106
+ readonly onEdit?: () => void
107
+ }
108
+
109
+ function FailedActions({ labels, onDiscard, onEdit }: FailedActionsProps) {
110
+ if (!onDiscard && !onEdit) return null
111
+ return (
112
+ <span className="cv-p-bubble__actions">
113
+ {onEdit ? (
114
+ <button type="button" className="cv-p-bubble__action" onClick={onEdit}>
115
+ {labels.edit}
116
+ </button>
117
+ ) : null}
118
+ {onDiscard ? (
119
+ <button type="button" className="cv-p-bubble__action" onClick={onDiscard}>
120
+ {labels.discard}
121
+ </button>
122
+ ) : null}
123
+ </span>
124
+ )
125
+ }
126
+
127
+ function toTextPayload(text: string, createdAt: string, isMine: boolean) {
128
+ return {
129
+ id: createdAt,
130
+ type: 'text' as const,
131
+ content: text,
132
+ direction: isMine ? ('inbound' as const) : ('outbound' as const),
133
+ sender: isMine ? ('customer' as const) : ('agent' as const),
134
+ timestamp: createdAt,
135
+ }
136
+ }
137
+
138
+ export function ParticipantMessageBubble({
139
+ item,
140
+ labels,
141
+ confirmsRead,
142
+ resolveAttachmentUrl,
143
+ onRetry,
144
+ onDiscard,
145
+ onEdit,
146
+ }: ParticipantMessageBubbleProps) {
147
+ const content = describeItem(item)
148
+ const { isMine } = content
149
+ const ownStatus = isMine
150
+ ? resolveOwnMessageStatus({ displayState: content.displayState, serverStatus: content.serverStatus, confirmsRead })
151
+ : undefined
152
+
153
+ return (
154
+ <div className={isMine ? 'cv-p-bubble cv-p-bubble--mine' : 'cv-p-bubble'}>
155
+ {isMine ? (
156
+ <span className="cv-p-sr-only">{labels.me}</span>
157
+ ) : content.authorName ? (
158
+ <span className="cv-p-bubble__author">{content.authorName}</span>
159
+ ) : null}
160
+ {content.text ? (
161
+ <MessageText message={toTextPayload(content.text, content.createdAt, isMine)} copyOnClick={false} />
162
+ ) : null}
163
+ {content.attachments.map((attachment) => (
164
+ <ParticipantAttachmentItem
165
+ key={attachment.id}
166
+ attachment={attachment}
167
+ resolveAttachmentUrl={resolveAttachmentUrl}
168
+ labels={labels}
169
+ />
170
+ ))}
171
+ {content.pendingFilenames.map((filename, index) => (
172
+ <span key={`${filename}-${index}`} className="cv-p-attachment cv-p-attachment--pending">
173
+ {filename}
174
+ </span>
175
+ ))}
176
+ <span className="cv-p-bubble__meta">
177
+ <time className="cv-p-bubble__time" dateTime={content.createdAt}>
178
+ {formatTimestamp(content.createdAt)}
179
+ </time>
180
+ {ownStatus ? <OwnStatus status={ownStatus} labels={labels} onRetry={onRetry} /> : null}
181
+ </span>
182
+ {ownStatus === 'failed' ? <FailedActions labels={labels} onDiscard={onDiscard} onEdit={onEdit} /> : null}
183
+ </div>
184
+ )
185
+ }
@@ -0,0 +1,132 @@
1
+ import { describe, expect, it } from 'bun:test'
2
+ import { renderToStaticMarkup } from 'react-dom/server'
3
+
4
+ import { buildConversation, buildMessage, findForeignClassTokens, findUtilityClassTokens } from './participantFixtures.test-helper'
5
+ import { DEFAULT_PARTICIPANT_CONVERSATIONS_LABELS } from './participantLabels'
6
+ import type { ParticipantTimelineItem } from './participantMessages'
7
+ import { ParticipantThread, type ParticipantThreadProps } from './ParticipantThread'
8
+
9
+ const ITEMS: readonly ParticipantTimelineItem[] = [
10
+ { kind: 'server', message: buildMessage({ id: 'a', createdAt: '2026-10-01T10:00:00.000Z' }) },
11
+ { kind: 'server', message: buildMessage({ id: 'b', direction: 'inbound', createdAt: '2026-10-01T10:05:00.000Z' }) },
12
+ { kind: 'server', message: buildMessage({ id: 'c', createdAt: '2026-10-02T09:00:00.000Z' }) },
13
+ ]
14
+
15
+ function render(overrides: Partial<ParticipantThreadProps> = {}): string {
16
+ return renderToStaticMarkup(
17
+ <ParticipantThread
18
+ conversation={buildConversation({ subjectId: '1', subjectLabel: 'Invoice 4521' })}
19
+ items={ITEMS}
20
+ hasMore={false}
21
+ labels={DEFAULT_PARTICIPANT_CONVERSATIONS_LABELS}
22
+ resolveAttachmentUrl={async () => 'https://files.example/a'}
23
+ draft={{ value: '', onChange: () => undefined, files: [], onFilesChange: () => undefined }}
24
+ onSend={() => undefined}
25
+ status="ready"
26
+ refresh={() => undefined}
27
+ scroll={{ ref: { current: null }, onScroll: () => undefined }}
28
+ newMessagesCount={0}
29
+ isSending={false}
30
+ {...overrides}
31
+ />,
32
+ )
33
+ }
34
+
35
+ describe('ParticipantThread', () => {
36
+ it('renders the subject label as the title', () => {
37
+ expect(render()).toContain('Invoice 4521')
38
+ })
39
+
40
+ it('renders the back button only with onBack', () => {
41
+ expect(render()).not.toContain('cv-p-thread__back')
42
+ expect(render({ onBack: () => undefined })).toContain('cv-p-thread__back')
43
+ })
44
+
45
+ it('makes the title clickable only with onOpenSubject', () => {
46
+ expect(render()).not.toContain('cv-p-thread__title-button')
47
+ expect(render({ onOpenSubject: () => undefined })).toContain('cv-p-thread__title-button')
48
+ })
49
+
50
+ it('draws nothing for the subject card slot when renderSubjectCard is absent', () => {
51
+ expect(render()).not.toContain('cv-p-thread__subject-card')
52
+ const markup = render({ renderSubjectCard: () => <p>Card body</p> })
53
+ expect(markup).toContain('cv-p-thread__subject-card')
54
+ expect(markup).toContain('Card body')
55
+ })
56
+
57
+ it('renders day dividers between days', () => {
58
+ expect(render().match(/cv-p-day"/g)?.length).toBe(2)
59
+ })
60
+
61
+ it('has a polite live region that announces new messages', () => {
62
+ const quiet = render()
63
+ const loud = render({ newMessagesCount: 2 })
64
+
65
+ expect(quiet).toContain('aria-live="polite"')
66
+ expect(quiet).not.toContain('New messages')
67
+ expect(loud).toContain('aria-live="polite"')
68
+ expect(loud).toContain('New messages')
69
+ })
70
+
71
+ it('while loading shows a busy skeleton', () => {
72
+ const markup = render({ items: [], status: 'loading' })
73
+
74
+ expect(markup).toContain('aria-busy="true"')
75
+ expect(markup).toContain('Loading…')
76
+ expect(render()).toContain('aria-busy="false"')
77
+ })
78
+
79
+ it('on error shows an alert with retry', () => {
80
+ const markup = render({ items: [], status: 'error' })
81
+
82
+ expect(markup).toContain('role="alert"')
83
+ expect(markup).toContain('Retry')
84
+ expect(markup).not.toContain('aria-busy="true"')
85
+ })
86
+
87
+ it('on success draws neither loading nor error', () => {
88
+ const markup = render()
89
+
90
+ expect(markup).not.toContain('role="alert"')
91
+ expect(markup).not.toContain('cv-p-loading')
92
+ })
93
+
94
+ it('disables only the send button while sending, never the field', () => {
95
+ const markup = render({ isSending: true, draft: { value: 'hi', onChange: () => undefined, files: [], onFilesChange: () => undefined } })
96
+
97
+ expect(markup).toMatch(/<button[^>]*type="submit"[^>]*disabled/)
98
+ expect(markup).not.toMatch(/<textarea[^>]*disabled/)
99
+ })
100
+
101
+ it('shows load older only when hasMore and onLoadOlder exist', () => {
102
+ expect(render()).not.toContain('Load older messages')
103
+ expect(render({ hasMore: true, onLoadOlder: () => undefined })).toContain('Load older messages')
104
+ })
105
+
106
+ it('shows the closed notice and no composer when the conversation is closed', () => {
107
+ const markup = render({ conversation: buildConversation({ subjectId: '1', status: 'closed' }) })
108
+
109
+ expect(markup).toContain('This conversation is closed')
110
+ expect(markup).not.toContain('<form')
111
+ expect(markup).not.toContain('<textarea')
112
+ })
113
+
114
+ it('shows the composer when the conversation is open', () => {
115
+ const markup = render()
116
+
117
+ expect(markup).toContain('<form')
118
+ expect(markup).not.toContain('This conversation is closed')
119
+ })
120
+
121
+ it('renders quick replies only when given', () => {
122
+ expect(render()).not.toContain('cv-p-quick')
123
+ expect(render({ quickReplies: [{ id: '1', title: 'Hi', shortcut: 'hi', body: 'Hi' }] })).toContain('cv-p-quick')
124
+ })
125
+
126
+ it('uses only cv-p-* classes and no Tailwind utilities', () => {
127
+ const markup = render({ onBack: () => undefined, hasMore: true, onLoadOlder: () => undefined })
128
+
129
+ expect(findUtilityClassTokens(markup)).toEqual([])
130
+ expect(findForeignClassTokens(markup)).toEqual([])
131
+ })
132
+ })
@@ -0,0 +1,174 @@
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 { bindPendingActions, resolveParticipantBubbleActions, type ParticipantPendingActions } from './participantBubbleActions'
12
+ import type { ParticipantConversationsLabels } from './participantLabels'
13
+ import { resolveLoadView, type ParticipantLoadStatus } from './participantLoadView'
14
+ import { ParticipantLoadError, ParticipantLoading } from './ParticipantLoadState'
15
+ import type { ParticipantThreadScroll } from './useStickToBottom'
16
+ import type { ParticipantTimelineItem } from './participantMessages'
17
+ import { ParticipantMessageBubble } from './ParticipantMessageBubble'
18
+
19
+ export type ParticipantThreadDraft = {
20
+ readonly value: string
21
+ readonly onChange: (value: string) => void
22
+ readonly files: readonly File[]
23
+ readonly onFilesChange: (files: readonly File[]) => void
24
+ }
25
+
26
+ export type ParticipantThreadProps = {
27
+ readonly conversation: ParticipantConversationSummary
28
+ readonly items: readonly ParticipantTimelineItem[]
29
+ readonly hasMore: boolean
30
+ readonly labels: ParticipantConversationsLabels
31
+ readonly resolveAttachmentUrl: ResolveParticipantAttachmentUrl
32
+ readonly draft: ParticipantThreadDraft
33
+ readonly onSend: () => void
34
+ readonly status: ParticipantLoadStatus
35
+ readonly refresh: () => void
36
+ /** Ref and onScroll of the scrolling area; the hook that owns the rule is useParticipantThreadScroll. */
37
+ readonly scroll: ParticipantThreadScroll
38
+ /** Messages from the other side that arrived while the user was reading above. */
39
+ readonly newMessagesCount: number
40
+ /** A send is in flight: only the send button waits, the field stays editable. */
41
+ readonly isSending: boolean
42
+ readonly channel?: ConversationChannel
43
+ readonly locale?: string
44
+ readonly onBack?: () => void
45
+ readonly onOpenSubject?: (subject: ParticipantSubjectRef) => void
46
+ readonly onLoadOlder?: () => void
47
+ readonly pendingActions?: ParticipantPendingActions
48
+ readonly quickReplies?: readonly QuickReply[]
49
+ readonly acceptedTypes?: readonly string[]
50
+ readonly maxLength?: number
51
+ readonly renderSubjectCard?: (conversation: ParticipantConversationSummary) => ReactNode
52
+ }
53
+
54
+ function createdAtOf(item: ParticipantTimelineItem): string {
55
+ return item.kind === 'server' ? item.message.createdAt : item.pending.createdAt
56
+ }
57
+
58
+ function keyOf(item: ParticipantTimelineItem): string {
59
+ return item.kind === 'server' ? item.message.id : `pending:${item.pending.clientMessageId}`
60
+ }
61
+
62
+ function resolveActions(item: ParticipantTimelineItem, pendingActions?: ParticipantPendingActions) {
63
+ if (!pendingActions || item.kind === 'server') return {}
64
+ return resolveParticipantBubbleActions(item, bindPendingActions(pendingActions, item.pending.clientMessageId))
65
+ }
66
+
67
+ function Header({ props }: { readonly props: ParticipantThreadProps }) {
68
+ const { conversation, labels, onBack, onOpenSubject } = props
69
+ const { subjectType, subjectId } = conversation
70
+
71
+ return (
72
+ <header className="cv-p-thread__header">
73
+ {onBack ? (
74
+ <button type="button" className="cv-p-button cv-p-thread__back" onClick={onBack}>
75
+ <span aria-hidden="true">‹</span>
76
+ <span className="cv-p-sr-only">{labels.back}</span>
77
+ </button>
78
+ ) : null}
79
+ <h2 className="cv-p-thread__title">
80
+ {onOpenSubject ? (
81
+ <button
82
+ type="button"
83
+ className="cv-p-thread__title-button"
84
+ aria-label={`${labels.openSubject}: ${conversation.subjectLabel}`}
85
+ onClick={() => onOpenSubject({ subjectType, subjectId })}
86
+ >
87
+ {conversation.subjectLabel}
88
+ </button>
89
+ ) : (
90
+ conversation.subjectLabel
91
+ )}
92
+ </h2>
93
+ </header>
94
+ )
95
+ }
96
+
97
+ function Timeline({ props }: { readonly props: ParticipantThreadProps }) {
98
+ const { items, labels, locale, resolveAttachmentUrl, pendingActions } = props
99
+ const confirmsRead = channelCapabilityFor(props.channel ?? 'app').confirmsRead
100
+ const nodes: ReactNode[] = []
101
+ let previousDay: Date | undefined
102
+
103
+ for (const item of items) {
104
+ const day = new Date(createdAtOf(item))
105
+ if (previousDay === undefined || !isSameDay(previousDay, day)) {
106
+ nodes.push(
107
+ <div key={`day:${keyOf(item)}`} className="cv-p-day" role="separator">
108
+ {day.toLocaleDateString(locale, { weekday: 'short', day: '2-digit', month: '2-digit' })}
109
+ </div>,
110
+ )
111
+ }
112
+ previousDay = day
113
+ nodes.push(
114
+ <ParticipantMessageBubble
115
+ key={keyOf(item)}
116
+ item={item}
117
+ labels={labels}
118
+ confirmsRead={confirmsRead}
119
+ resolveAttachmentUrl={resolveAttachmentUrl}
120
+ {...resolveActions(item, pendingActions)}
121
+ />,
122
+ )
123
+ }
124
+ return <div className="cv-p-thread__messages">{nodes}</div>
125
+ }
126
+
127
+ function Footer({ props }: { readonly props: ParticipantThreadProps }) {
128
+ const { conversation, labels, draft } = props
129
+ if (conversation.status === 'closed') {
130
+ return <p className="cv-p-thread__closed">{labels.closedNotice}</p>
131
+ }
132
+ return (
133
+ <ParticipantComposer
134
+ value={draft.value}
135
+ onChange={draft.onChange}
136
+ files={draft.files}
137
+ onFilesChange={draft.onFilesChange}
138
+ onSend={props.onSend}
139
+ labels={labels}
140
+ channel={props.channel}
141
+ isSending={props.isSending}
142
+ maxLength={props.maxLength}
143
+ acceptedTypes={props.acceptedTypes}
144
+ quickReplies={props.quickReplies}
145
+ />
146
+ )
147
+ }
148
+
149
+ export function ParticipantThread(props: ParticipantThreadProps) {
150
+ const { conversation, labels, hasMore, onLoadOlder, renderSubjectCard } = props
151
+ const subjectCard = renderSubjectCard?.(conversation)
152
+ const loadView = resolveLoadView({ status: props.status, hasItems: props.items.length > 0 })
153
+
154
+ return (
155
+ <div className="cv-p cv-p-thread" aria-busy={loadView.isLoading}>
156
+ <Header props={props} />
157
+ {subjectCard ? <div className="cv-p-thread__subject-card">{subjectCard}</div> : null}
158
+ <div className="cv-p-thread__scroll" ref={props.scroll.ref} onScroll={props.scroll.onScroll}>
159
+ {loadView.isLoading ? <ParticipantLoading labels={labels} /> : null}
160
+ {loadView.hasError ? <ParticipantLoadError labels={labels} onRetry={props.refresh} /> : null}
161
+ {hasMore && onLoadOlder ? (
162
+ <button type="button" className="cv-p-thread__older" onClick={onLoadOlder}>
163
+ {labels.loadOlder}
164
+ </button>
165
+ ) : null}
166
+ <Timeline props={props} />
167
+ </div>
168
+ <div className="cv-p-thread__live" role="status" aria-live="polite">
169
+ {props.newMessagesCount > 0 ? labels.newMessages : ''}
170
+ </div>
171
+ <Footer props={props} />
172
+ </div>
173
+ )
174
+ }