@adatechnology/conversations-ui 0.4.2 → 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.
- package/README.md +169 -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-HMCOTZAX.js → chunk-HOKNKXIT.js} +1 -1
- package/dist/{chunk-4P5KMWOI.js → chunk-JXJCPN34.js} +327 -522
- package/dist/chunk-MRVFFJEH.js +64 -0
- package/dist/chunk-RJMSM3YA.js +249 -0
- package/dist/chunk-WSFGNR5R.js +55 -0
- package/dist/flows/index.js +5 -3
- package/dist/index.d.ts +9 -3
- package/dist/index.js +783 -807
- package/dist/participant/index.d.ts +162 -0
- package/dist/participant/index.js +1448 -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 +591 -0
- package/package.json +8 -4
- package/src/ChannelIcon.tsx +2 -0
- package/src/MessageBubble.test.tsx +2 -2
- package/src/MessageText.test.tsx +59 -0
- package/src/MessageText.tsx +20 -12
- package/src/MessageTextCopiedBadge.tsx +7 -0
- package/src/StatusTicks.test.tsx +30 -0
- package/src/StatusTicks.tsx +24 -9
- 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/participant/ParticipantAttachmentItem.tsx +77 -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 +42 -0
- package/src/participant/ParticipantConversationsScreen.tsx +67 -0
- package/src/participant/ParticipantInbox.test.tsx +147 -0
- package/src/participant/ParticipantInbox.tsx +168 -0
- package/src/participant/ParticipantLoadState.tsx +27 -0
- package/src/participant/ParticipantMessageBubble.test.tsx +165 -0
- package/src/participant/ParticipantMessageBubble.tsx +185 -0
- package/src/participant/ParticipantThread.test.tsx +132 -0
- package/src/participant/ParticipantThread.tsx +174 -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 +25 -0
- package/src/participant/participant.types.ts +43 -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/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 +138 -0
- package/src/participant/participantGrouping.ts +77 -0
- package/src/participant/participantInboxState.test.ts +69 -0
- package/src/participant/participantInboxState.ts +74 -0
- package/src/participant/participantLabels.ts +86 -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/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 +160 -0
- 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
|
+
}
|