@adatechnology/conversations-ui 0.6.1 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +221 -19
- package/dist/{ConversationSimulatorPanel-CrfFjiWh.d.ts → ConversationSimulatorPanel-q-wVgBCN.d.ts} +1 -1
- package/dist/{chunk-HOKNKXIT.js → chunk-HMCOTZAX.js} +1 -1
- package/dist/{chunk-FAQLC3TX.js → chunk-L3OFAQPS.js} +2 -4
- package/dist/{chunk-CBYEHPX6.js → chunk-PJNR6UMN.js} +63 -1
- package/dist/flows/index.js +3 -5
- package/dist/index.d.ts +4 -4
- package/dist/index.js +834 -764
- package/dist/participant/index.d.ts +62 -3
- package/dist/participant/index.js +1444 -394
- package/dist/preview/index.d.ts +3 -3
- package/dist/preview/index.js +2 -3
- package/dist/{quickReply.types-B4gmZsSr.d.ts → quickReply.types-DzWH3NYV.d.ts} +1 -1
- package/dist/styles.css +629 -20
- package/package.json +3 -3
- package/src/participant/ConversationThread.test.tsx +165 -0
- package/src/participant/ConversationThread.tsx +117 -0
- package/src/participant/ParticipantAuthorAvatar.test.tsx +30 -0
- package/src/participant/ParticipantAuthorAvatar.tsx +24 -0
- package/src/participant/ParticipantBackButton.tsx +14 -0
- package/src/participant/ParticipantBubbleStatus.tsx +67 -0
- package/src/participant/ParticipantChannelBadges.tsx +13 -4
- package/src/participant/ParticipantComposer.tsx +8 -40
- package/src/participant/ParticipantComposerControls.tsx +83 -0
- package/src/participant/ParticipantConversationAvatar.tsx +13 -0
- package/src/participant/ParticipantConversations.tsx +7 -0
- package/src/participant/ParticipantConversationsOptions.test.tsx +87 -0
- package/src/participant/ParticipantCopyAnnouncer.tsx +48 -0
- package/src/participant/ParticipantCopyableToken.tsx +51 -0
- package/src/participant/ParticipantDayDivider.tsx +18 -0
- package/src/participant/ParticipantInbox.tsx +3 -1
- package/src/participant/ParticipantInboxRow.tsx +6 -4
- package/src/participant/ParticipantMessageBubble.test.tsx +52 -5
- package/src/participant/ParticipantMessageBubble.tsx +32 -84
- package/src/participant/ParticipantMessageNodes.tsx +54 -0
- package/src/participant/ParticipantMessageText.test.tsx +106 -0
- package/src/participant/ParticipantMessageText.tsx +52 -0
- package/src/participant/ParticipantProtocolBadge.tsx +15 -7
- package/src/participant/ParticipantProtocolView.test.tsx +4 -6
- package/src/participant/ParticipantScrollToLatest.test.tsx +153 -0
- package/src/participant/ParticipantScrollToLatest.tsx +40 -0
- package/src/participant/ParticipantThread.test.tsx +37 -0
- package/src/participant/ParticipantThread.tsx +54 -95
- package/src/participant/ParticipantThreadAvatarSequence.test.tsx +65 -0
- package/src/participant/ParticipantThreadHeader.test.tsx +181 -0
- package/src/participant/ParticipantThreadHeader.tsx +83 -0
- package/src/participant/ParticipantThreadScreen.tsx +30 -56
- package/src/participant/ParticipantTimeline.tsx +106 -0
- package/src/participant/conversationThreadApi.test.ts +27 -0
- package/src/participant/conversationThreadApi.ts +24 -0
- package/src/participant/conversationThreadCss.test.ts +116 -0
- package/src/participant/conversationThreadSummary.ts +34 -0
- package/src/participant/index.ts +4 -0
- package/src/participant/participantAutoGrow.test.ts +43 -0
- package/src/participant/participantAutoGrow.ts +12 -0
- package/src/participant/participantAvatar.test.ts +74 -0
- package/src/participant/participantAvatar.ts +48 -0
- package/src/participant/participantBubbleTail.test.tsx +107 -0
- package/src/participant/participantChatDesign.test.tsx +198 -0
- package/src/participant/participantContrast.test-helper.ts +116 -0
- package/src/participant/participantCopyAnnouncement.test.ts +52 -0
- package/src/participant/participantCopyAnnouncement.ts +37 -0
- package/src/participant/participantCss.test-helper.ts +34 -0
- package/src/participant/participantDay.test.ts +22 -0
- package/src/participant/participantDay.ts +8 -0
- package/src/participant/participantDocuments.ts +45 -0
- package/src/participant/participantGolden.test.tsx +46 -0
- package/src/participant/participantGoldenScenarios.test-helper.tsx +57 -0
- package/src/participant/participantHeaderA11y.test.tsx +85 -0
- package/src/participant/participantHeaderDesign.test.tsx +111 -0
- package/src/participant/participantLabels.ts +11 -0
- package/src/participant/participantMessageAtoms.ts +53 -0
- package/src/participant/participantMessageCopyables.test.ts +141 -0
- package/src/participant/participantMessageCopyables.ts +76 -0
- package/src/participant/participantMessageFormat.test-helper.ts +21 -0
- package/src/participant/participantMessageFormat.test.ts +177 -0
- package/src/participant/participantMessageFormat.ts +135 -0
- package/src/participant/participantMessageFormat.types.ts +22 -0
- package/src/participant/participantMessageInline.test.ts +82 -0
- package/src/participant/participantMessageInline.ts +99 -0
- package/src/participant/participantMessageLinks.test.ts +100 -0
- package/src/participant/participantMessageLinks.ts +54 -0
- package/src/participant/participantMessageTextStyle.test.ts +137 -0
- package/src/participant/participantMessages.ts +4 -3
- package/src/participant/participantNewMessages.test.ts +21 -0
- package/src/participant/participantNewMessages.ts +8 -3
- package/src/participant/participantPerspective.test.ts +67 -0
- package/src/participant/participantPerspective.ts +50 -0
- package/src/participant/participantPerspectiveRender.test.tsx +94 -0
- package/src/participant/participantScrollFocus.test.ts +22 -0
- package/src/participant/participantScrollFocus.ts +10 -0
- package/src/participant/participantScrollView.test.ts +74 -0
- package/src/participant/participantScrollView.ts +38 -0
- package/src/participant/participantScrollbars.test.ts +156 -0
- package/src/participant/participantSubjectIcon.test.tsx +27 -0
- package/src/participant/participantSubjectIcon.ts +26 -0
- package/src/participant/participantThemeContrast.test.ts +98 -0
- package/src/participant/test-fixtures/conversations-list.golden.html +1 -0
- package/src/participant/test-fixtures/conversations-selected.golden.html +1 -0
- package/src/participant/test-fixtures/thread-avatars-initials.golden.html +1 -0
- package/src/participant/test-fixtures/thread-closed.golden.html +1 -0
- package/src/participant/test-fixtures/thread-default.golden.html +1 -0
- package/src/participant/test-fixtures/thread-error.golden.html +1 -0
- package/src/participant/test-fixtures/thread-loading.golden.html +1 -0
- package/src/participant/test-fixtures/thread-plain.golden.html +1 -0
- package/src/participant/test-fixtures/thread-tail-off.golden.html +1 -0
- package/src/participant/useParticipantConversationView.ts +3 -0
- package/src/participant/useParticipantCopyFeedback.ts +33 -0
- package/src/participant/useParticipantThread.ts +13 -2
- package/src/participant/useParticipantThreadController.ts +112 -0
- package/src/participant/useParticipantThreadScroll.ts +13 -3
- package/src/participant/useParticipantUnread.test.tsx +40 -0
- package/src/participant/useParticipantUnread.ts +38 -0
- package/src/participant/useRevealActiveChip.ts +20 -0
- package/src/participant/useScrollAwayState.ts +24 -0
- package/src/participant/useStickToBottom.ts +16 -2
- package/src/styles.css +151 -23
- package/dist/chunk-MRVFFJEH.js +0 -64
- package/dist/chunk-OVFCLRQQ.js +0 -84
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@adatechnology/conversations-ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"description": "WhatsApp conversation UI components — parametrizável por endpoint, tema e feature flags",
|
|
5
5
|
"publishConfig": {
|
|
6
6
|
"access": "public"
|
|
@@ -35,8 +35,8 @@
|
|
|
35
35
|
"clsx": "^2.1.1",
|
|
36
36
|
"lucide-react": "^1.21.0",
|
|
37
37
|
"tailwind-merge": "^3.6.0",
|
|
38
|
-
"@adatechnology/
|
|
39
|
-
"@adatechnology/
|
|
38
|
+
"@adatechnology/conversation-contracts": "0.5.0",
|
|
39
|
+
"@adatechnology/meta-whatsapp-contracts": "0.6.0"
|
|
40
40
|
},
|
|
41
41
|
"peerDependencies": {
|
|
42
42
|
"@xyflow/react": "^12",
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { afterAll, beforeAll, describe, expect, it, mock } from 'bun:test'
|
|
2
|
+
import { renderToStaticMarkup } from 'react-dom/server'
|
|
3
|
+
|
|
4
|
+
import type { ParticipantMessage } from '@adatechnology/conversation-contracts'
|
|
5
|
+
|
|
6
|
+
import type { ConversationThreadApi } from './conversationThreadApi'
|
|
7
|
+
import type { ConversationThreadProps } from './ConversationThread'
|
|
8
|
+
import { buildMessage, findForeignClassTokens, findUtilityClassTokens } from './participantFixtures.test-helper'
|
|
9
|
+
import * as realThreadModule from './useParticipantThread'
|
|
10
|
+
|
|
11
|
+
const COUNTERPART = buildMessage({
|
|
12
|
+
id: 'a',
|
|
13
|
+
direction: 'inbound',
|
|
14
|
+
authorName: 'Ana Souza',
|
|
15
|
+
createdAt: '2026-10-01T10:00:00.000Z',
|
|
16
|
+
})
|
|
17
|
+
const OWN_READ = buildMessage({ id: 'b', direction: 'outbound', status: 'read', createdAt: '2026-10-01T10:01:00.000Z' })
|
|
18
|
+
const OWN_DELIVERED = buildMessage({
|
|
19
|
+
id: 'c',
|
|
20
|
+
direction: 'outbound',
|
|
21
|
+
status: 'delivered',
|
|
22
|
+
createdAt: '2026-10-01T10:02:00.000Z',
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
const realUseParticipantThread = realThreadModule.useParticipantThread
|
|
26
|
+
let injected: readonly ParticipantMessage[] | undefined
|
|
27
|
+
|
|
28
|
+
const API: ConversationThreadApi = {
|
|
29
|
+
fetchMessages: async () => [],
|
|
30
|
+
sendMessage: async () => ({ outcome: 'queued' }),
|
|
31
|
+
resolveAttachmentUrl: async () => 'https://files.example/a',
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
let ConversationThread: typeof import('./ConversationThread').ConversationThread
|
|
35
|
+
|
|
36
|
+
beforeAll(async () => {
|
|
37
|
+
// Server rendering runs no effects, so the messages are injected over the real hook.
|
|
38
|
+
mock.module('./useParticipantThread', () => ({
|
|
39
|
+
...realThreadModule,
|
|
40
|
+
useParticipantThread: (...args: Parameters<typeof realUseParticipantThread>) => {
|
|
41
|
+
const result = realUseParticipantThread(...args)
|
|
42
|
+
return injected ? { ...result, messages: injected, status: 'ready' as const } : result
|
|
43
|
+
},
|
|
44
|
+
}))
|
|
45
|
+
ConversationThread = (await import('./ConversationThread')).ConversationThread
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
afterAll(() => {
|
|
49
|
+
injected = undefined
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
function render(messages: readonly ParticipantMessage[], overrides: Partial<ConversationThreadProps> = {}): string {
|
|
53
|
+
injected = messages
|
|
54
|
+
return renderToStaticMarkup(
|
|
55
|
+
<ConversationThread api={API} subject={{ subjectType: 'case', subjectId: '7' }} title="Case 7" {...overrides} />,
|
|
56
|
+
)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function bubblesOf(markup: string): string[] {
|
|
60
|
+
return markup.split('<div class="cv-p-bubble').slice(1)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
describe('ConversationThread, operator perspective', () => {
|
|
64
|
+
const MESSAGES = [COUNTERPART, OWN_READ, OWN_DELIVERED]
|
|
65
|
+
|
|
66
|
+
it('owns the outbound messages and not the inbound ones', () => {
|
|
67
|
+
const bubbles = bubblesOf(render(MESSAGES, { perspective: 'operator' }))
|
|
68
|
+
expect(bubbles.map((bubble) => bubble.startsWith(' cv-p-bubble--mine'))).toEqual([false, true, true])
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it('draws the ticks on the operator messages only', () => {
|
|
72
|
+
const [received, readOwn, deliveredOwn] = bubblesOf(render(MESSAGES, { perspective: 'operator' }))
|
|
73
|
+
expect(received).not.toContain('cv-status-ticks')
|
|
74
|
+
expect(readOwn).toContain('cv-status-ticks--read')
|
|
75
|
+
expect(deliveredOwn).toContain('cv-status-ticks--delivered')
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
it('draws the initials on the counterpart and never on the operator messages', () => {
|
|
79
|
+
const markup = render(MESSAGES, { perspective: 'operator', avatars: 'initials' })
|
|
80
|
+
expect(markup.match(/class="cv-p-avatar" aria-hidden="true">AS</g)?.length).toBe(1)
|
|
81
|
+
expect(markup.match(/class="cv-p-bubble-row"/g)?.length).toBe(1)
|
|
82
|
+
expect(render([OWN_READ, OWN_DELIVERED], { perspective: 'operator', avatars: 'initials' })).not.toContain(
|
|
83
|
+
'cv-p-avatar',
|
|
84
|
+
)
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
it('labels the received bubble with the author name', () => {
|
|
88
|
+
expect(render(MESSAGES, { perspective: 'operator' })).toContain(
|
|
89
|
+
'<span class="cv-p-bubble__author">Ana Souza</span>',
|
|
90
|
+
)
|
|
91
|
+
})
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
describe('ConversationThread, default perspective', () => {
|
|
95
|
+
const MESSAGES = [COUNTERPART, OWN_READ]
|
|
96
|
+
|
|
97
|
+
it('is the participant: inbound is mine, and naming it explicitly changes nothing', () => {
|
|
98
|
+
const implicit = render(MESSAGES)
|
|
99
|
+
expect(bubblesOf(implicit).map((bubble) => bubble.startsWith(' cv-p-bubble--mine'))).toEqual([true, false])
|
|
100
|
+
expect(render(MESSAGES, { perspective: 'participant' })).toBe(implicit)
|
|
101
|
+
})
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
describe('ConversationThread, header and footer', () => {
|
|
105
|
+
it('shows the host actions and the counterpart line in the header, and nothing without them', () => {
|
|
106
|
+
const plain = render([COUNTERPART])
|
|
107
|
+
expect(plain).not.toContain('cv-p-thread__actions')
|
|
108
|
+
expect(plain).not.toContain('cv-p-thread__counterpart')
|
|
109
|
+
const markup = render([COUNTERPART], {
|
|
110
|
+
headerActions: <button type="button">Close</button>,
|
|
111
|
+
counterpartLabel: 'Participant: Ana',
|
|
112
|
+
})
|
|
113
|
+
expect(markup).toContain('<div class="cv-p-thread__actions"><button type="button">Close</button></div>')
|
|
114
|
+
expect(markup).toContain('<p class="cv-p-thread__counterpart">Participant: Ana</p>')
|
|
115
|
+
expect(markup.indexOf('cv-p-thread__actions')).toBeLessThan(markup.indexOf('</header>'))
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
it('removes the composer and shows the notice when closed, and keeps it when open', () => {
|
|
119
|
+
const closed = render([COUNTERPART], { status: 'closed', perspective: 'operator' })
|
|
120
|
+
expect(closed).toContain('This conversation is closed')
|
|
121
|
+
expect(closed).not.toContain('<form')
|
|
122
|
+
expect(render([COUNTERPART], { status: 'open' })).toContain('<form')
|
|
123
|
+
expect(render([COUNTERPART])).toContain('<form')
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
it('draws the title, protocol and channels only from what the host gave', () => {
|
|
127
|
+
expect(render([COUNTERPART])).not.toContain('cv-p-protocol')
|
|
128
|
+
expect(render([COUNTERPART])).not.toContain('cv-p-channels')
|
|
129
|
+
const markup = render([COUNTERPART], { protocol: '261009-K7M2', channels: ['app', 'whatsapp'] })
|
|
130
|
+
expect(markup).toContain('261009-K7M2')
|
|
131
|
+
expect(markup).toContain('cv-p-channel--whatsapp')
|
|
132
|
+
expect(markup).toContain('Case 7')
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
it('draws no back arrow without onBack', () => {
|
|
136
|
+
expect(render([COUNTERPART])).not.toContain('cv-p-thread__back')
|
|
137
|
+
expect(render([COUNTERPART], { onBack: () => undefined })).toContain('cv-p-thread__back')
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
it('offers quick replies as plain buttons that only fill the field', () => {
|
|
141
|
+
const markup = render([COUNTERPART], {
|
|
142
|
+
quickReplies: [{ id: '1', title: 'Thanks', shortcut: 'ty', body: 'Thank you' }],
|
|
143
|
+
})
|
|
144
|
+
const chips = [...markup.matchAll(/<button[^>]*class="cv-p-chip"[^>]*>/g)].map((match) => match[0])
|
|
145
|
+
expect(chips).toHaveLength(1)
|
|
146
|
+
expect(chips[0]).toContain('type="button"')
|
|
147
|
+
expect(render([COUNTERPART])).not.toContain('cv-p-quick')
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
it('applies the theme and the class on a wrapper and uses only the package classes', () => {
|
|
151
|
+
const markup = render([COUNTERPART, OWN_READ], {
|
|
152
|
+
theme: { primaryColor: '#123456' },
|
|
153
|
+
className: 'host-panel',
|
|
154
|
+
onBack: () => undefined,
|
|
155
|
+
})
|
|
156
|
+
expect(markup).toContain('--cv-p-accent:#123456')
|
|
157
|
+
expect(markup).toContain('cv-p-root host-panel')
|
|
158
|
+
expect(findUtilityClassTokens(markup.replace('host-panel', ''))).toEqual([])
|
|
159
|
+
expect(findForeignClassTokens(markup.replace('host-panel', ''))).toEqual([])
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
it('draws the conversation without any provider or list around it', () => {
|
|
163
|
+
expect(render([])).toContain('cv-p-thread__title')
|
|
164
|
+
})
|
|
165
|
+
})
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { useCallback, useMemo, useReducer, useRef, type ReactNode } from 'react'
|
|
2
|
+
|
|
3
|
+
import type { ParticipantConversationSummary, ParticipantSubjectRef } from '@adatechnology/conversation-contracts'
|
|
4
|
+
|
|
5
|
+
import type { ConversationChannel } from '../conversationChannel'
|
|
6
|
+
import type { QuickReply } from '../quickReplies/quickReply.types'
|
|
7
|
+
import type { ConversationsTheme } from '../types'
|
|
8
|
+
import type { ParticipantAuthorAvatarRenderer } from './ParticipantAuthorAvatar'
|
|
9
|
+
import { ParticipantThread } from './ParticipantThread'
|
|
10
|
+
import { toParticipantApi, type ConversationThreadApi } from './conversationThreadApi'
|
|
11
|
+
import { buildThreadSummary } from './conversationThreadSummary'
|
|
12
|
+
import type { ParticipantSubjectGroup, ParticipantSubjectIconRenderer } from './participant.types'
|
|
13
|
+
import type { ParticipantPendingMessage } from './participantApi.types'
|
|
14
|
+
import { draftKey, type ParticipantDrafts } from './participantDrafts'
|
|
15
|
+
import { DEFAULT_PARTICIPANT_CONVERSATIONS_LABELS, type ParticipantConversationsLabels } from './participantLabels'
|
|
16
|
+
import type { ParticipantPerspective } from './participantPerspective'
|
|
17
|
+
import { participantSendStatesReducer } from './participantSendStates'
|
|
18
|
+
import { buildParticipantThemeStyle } from './participantTheme'
|
|
19
|
+
import { useParticipantThreadController, type ParticipantThreadCoreProps } from './useParticipantThreadController'
|
|
20
|
+
|
|
21
|
+
export type ConversationThreadProps = {
|
|
22
|
+
readonly api: ConversationThreadApi
|
|
23
|
+
readonly subject: ParticipantSubjectRef
|
|
24
|
+
/** `participant` (default) is the outside party; `operator` is the company, which owns the outbound messages. */
|
|
25
|
+
readonly perspective?: ParticipantPerspective
|
|
26
|
+
readonly title: string
|
|
27
|
+
readonly protocol?: string
|
|
28
|
+
readonly channels?: ParticipantConversationSummary['channels']
|
|
29
|
+
/** `closed` removes the composer and shows `labels.closedNotice`. */
|
|
30
|
+
readonly status?: 'open' | 'closed'
|
|
31
|
+
/** Short line under the title, written by the host (for example "Participant: Ana"). */
|
|
32
|
+
readonly counterpartLabel?: string
|
|
33
|
+
/** Slot at the end of the header bar for the host's own actions (close, reopen...). The package knows none of them. */
|
|
34
|
+
readonly headerActions?: ReactNode
|
|
35
|
+
/** Absent draws no back arrow. */
|
|
36
|
+
readonly onBack?: () => void
|
|
37
|
+
readonly renderSubjectIcon?: ParticipantSubjectIconRenderer
|
|
38
|
+
readonly subjectGroups?: readonly ParticipantSubjectGroup[]
|
|
39
|
+
/** Chips above the field: tapping one fills the text, it never sends. */
|
|
40
|
+
readonly quickReplies?: readonly QuickReply[]
|
|
41
|
+
readonly labels?: Partial<ParticipantConversationsLabels>
|
|
42
|
+
readonly theme?: ConversationsTheme
|
|
43
|
+
readonly className?: string
|
|
44
|
+
readonly locale?: string
|
|
45
|
+
readonly channel?: ConversationChannel
|
|
46
|
+
readonly pendingMessages?: readonly ParticipantPendingMessage[]
|
|
47
|
+
readonly onRetryPending?: (clientMessageId: string) => void
|
|
48
|
+
/** Tells the host the other side's messages were marked read, to refresh its own badges. */
|
|
49
|
+
readonly onMarkedRead?: (subject: ParticipantSubjectRef) => void
|
|
50
|
+
readonly tail?: boolean
|
|
51
|
+
readonly avatars?: 'initials'
|
|
52
|
+
readonly renderAuthorAvatar?: ParticipantAuthorAvatarRenderer
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function useConversationThreadCore(props: ConversationThreadProps): ParticipantThreadCoreProps {
|
|
56
|
+
const { perspective = 'participant' } = props
|
|
57
|
+
const { subjectType, subjectId } = props.subject
|
|
58
|
+
const subject = useMemo<ParticipantSubjectRef>(() => ({ subjectType, subjectId }), [subjectType, subjectId])
|
|
59
|
+
const onMarkedReadRef = useRef(props.onMarkedRead)
|
|
60
|
+
onMarkedReadRef.current = props.onMarkedRead
|
|
61
|
+
const handleMarkedRead = useCallback((marked: ParticipantSubjectRef) => onMarkedReadRef.current?.(marked), [])
|
|
62
|
+
const api = useMemo(() => toParticipantApi(props.api), [props.api])
|
|
63
|
+
const draftsRef = useRef<ParticipantDrafts>(new Map())
|
|
64
|
+
const [sendStates, dispatchSendStates] = useReducer(participantSendStatesReducer, new Map())
|
|
65
|
+
const labels = useMemo(() => ({ ...DEFAULT_PARTICIPANT_CONVERSATIONS_LABELS, ...props.labels }), [props.labels])
|
|
66
|
+
const { title, protocol, channels, status } = props
|
|
67
|
+
const conversation = useMemo(
|
|
68
|
+
() => buildThreadSummary({ subject, title, protocol, channels, status }),
|
|
69
|
+
[subject, title, protocol, channels, status],
|
|
70
|
+
)
|
|
71
|
+
return useParticipantThreadController({
|
|
72
|
+
api,
|
|
73
|
+
subject,
|
|
74
|
+
conversation,
|
|
75
|
+
labels,
|
|
76
|
+
draftsRef,
|
|
77
|
+
sendStates,
|
|
78
|
+
dispatchSendStates,
|
|
79
|
+
onMarkedRead: handleMarkedRead,
|
|
80
|
+
channel: props.channel,
|
|
81
|
+
locale: props.locale,
|
|
82
|
+
pendingMessages: props.pendingMessages,
|
|
83
|
+
onRetryPending: props.onRetryPending,
|
|
84
|
+
perspective,
|
|
85
|
+
})
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function ConversationThreadBody(props: ConversationThreadProps) {
|
|
89
|
+
const core = useConversationThreadCore(props)
|
|
90
|
+
|
|
91
|
+
return (
|
|
92
|
+
<div
|
|
93
|
+
className={['cv-p-root', props.className].filter(Boolean).join(' ')}
|
|
94
|
+
style={buildParticipantThemeStyle(props.theme)}
|
|
95
|
+
>
|
|
96
|
+
<div className="cv-p-root__screen">
|
|
97
|
+
<ParticipantThread
|
|
98
|
+
{...core}
|
|
99
|
+
onBack={props.onBack}
|
|
100
|
+
quickReplies={props.quickReplies}
|
|
101
|
+
subjectGroups={props.subjectGroups}
|
|
102
|
+
renderSubjectIcon={props.renderSubjectIcon}
|
|
103
|
+
avatars={props.avatars}
|
|
104
|
+
renderAuthorAvatar={props.renderAuthorAvatar}
|
|
105
|
+
tail={props.tail}
|
|
106
|
+
counterpartLabel={props.counterpartLabel}
|
|
107
|
+
headerActions={props.headerActions}
|
|
108
|
+
/>
|
|
109
|
+
</div>
|
|
110
|
+
</div>
|
|
111
|
+
)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** One conversation about one subject, drawn like the participant app and seen from either side. */
|
|
115
|
+
export function ConversationThread(props: ConversationThreadProps) {
|
|
116
|
+
return <ConversationThreadBody key={draftKey(props.subject)} {...props} />
|
|
117
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test'
|
|
2
|
+
import type { ReactNode } from 'react'
|
|
3
|
+
import { renderToStaticMarkup } from 'react-dom/server'
|
|
4
|
+
|
|
5
|
+
import { ParticipantAuthorAvatar } from './ParticipantAuthorAvatar'
|
|
6
|
+
|
|
7
|
+
function render(hostResult: unknown): string {
|
|
8
|
+
return renderToStaticMarkup(<ParticipantAuthorAvatar name="Ana Souza" render={() => hostResult as ReactNode} />)
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
describe('ParticipantAuthorAvatar host slot', () => {
|
|
12
|
+
it('uses the host content when it returns something', () => {
|
|
13
|
+
const markup = render(<img src="https://files.example/ana.png" alt="" />)
|
|
14
|
+
expect(markup).toContain('ana.png')
|
|
15
|
+
expect(markup).not.toContain('>AS<')
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
it.each([
|
|
19
|
+
['false', false],
|
|
20
|
+
['an empty string', ''],
|
|
21
|
+
['null', null],
|
|
22
|
+
['undefined', undefined],
|
|
23
|
+
])('falls back to the initials when the host returns %s', (_label, hostResult) => {
|
|
24
|
+
expect(render(hostResult)).toContain('>AS<')
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it('keeps zero as host content', () => {
|
|
28
|
+
expect(render(0)).toContain('>0<')
|
|
29
|
+
})
|
|
30
|
+
})
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { User } from 'lucide-react'
|
|
2
|
+
import type { ReactNode } from 'react'
|
|
3
|
+
|
|
4
|
+
import { authorInitials } from './participantAvatar'
|
|
5
|
+
|
|
6
|
+
export type ParticipantAuthorAvatarRenderer = (author: { name: string | null }) => ReactNode
|
|
7
|
+
|
|
8
|
+
export type ParticipantAuthorAvatarProps = {
|
|
9
|
+
readonly name: string | null
|
|
10
|
+
/** Host slot (a photo, for instance); its result wins over the initials. */
|
|
11
|
+
readonly render?: ParticipantAuthorAvatarRenderer
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function ParticipantAuthorAvatar({ name, render }: ParticipantAuthorAvatarProps) {
|
|
15
|
+
const fromHost = render?.({ name })
|
|
16
|
+
const hasHostContent = !(fromHost == null || fromHost === false || fromHost === '')
|
|
17
|
+
const initials = authorInitials(name)
|
|
18
|
+
const fallback = initials ?? <User size={16} aria-hidden={true} />
|
|
19
|
+
return (
|
|
20
|
+
<span className="cv-p-avatar" aria-hidden="true">
|
|
21
|
+
{hasHostContent ? fromHost : fallback}
|
|
22
|
+
</span>
|
|
23
|
+
)
|
|
24
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { ArrowLeft } from 'lucide-react'
|
|
2
|
+
|
|
3
|
+
export type ParticipantBackButtonProps = {
|
|
4
|
+
readonly label: string
|
|
5
|
+
readonly onBack: () => void
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function ParticipantBackButton({ label, onBack }: ParticipantBackButtonProps) {
|
|
9
|
+
return (
|
|
10
|
+
<button type="button" className="cv-p-thread__back" aria-label={label} onClick={onBack}>
|
|
11
|
+
<ArrowLeft size={24} aria-hidden="true" focusable="false" />
|
|
12
|
+
</button>
|
|
13
|
+
)
|
|
14
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { StatusTicks } from '../StatusTicks'
|
|
2
|
+
import type { ParticipantConversationsLabels } from './participantLabels'
|
|
3
|
+
import type { ParticipantOwnMessageStatus } from './participantMessages'
|
|
4
|
+
|
|
5
|
+
function statusText(status: ParticipantOwnMessageStatus, labels: ParticipantConversationsLabels): string {
|
|
6
|
+
const byStatus: Record<ParticipantOwnMessageStatus, string> = {
|
|
7
|
+
sending: labels.statusSending,
|
|
8
|
+
queued: labels.statusQueued,
|
|
9
|
+
sent: labels.statusSent,
|
|
10
|
+
delivered: labels.statusDelivered,
|
|
11
|
+
read: labels.statusRead,
|
|
12
|
+
failed: labels.statusFailed,
|
|
13
|
+
}
|
|
14
|
+
return byStatus[status]
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
type OwnStatusProps = {
|
|
18
|
+
readonly status: ParticipantOwnMessageStatus
|
|
19
|
+
readonly labels: ParticipantConversationsLabels
|
|
20
|
+
readonly onRetry?: () => void
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function OwnStatus({ status, labels, onRetry }: OwnStatusProps) {
|
|
24
|
+
const ticksStatus = status === 'sending' ? 'queued' : status
|
|
25
|
+
const isVisibleText = status === 'sending' || status === 'queued' || status === 'failed'
|
|
26
|
+
|
|
27
|
+
if (status === 'failed' && onRetry) {
|
|
28
|
+
return (
|
|
29
|
+
<button type="button" className="cv-p-bubble__retry" onClick={onRetry}>
|
|
30
|
+
<StatusTicks status="failed" appearance="stylesheet" />
|
|
31
|
+
<span>{labels.statusFailed}</span>
|
|
32
|
+
</button>
|
|
33
|
+
)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const text = status === 'failed' ? labels.statusFailedShort : statusText(status, labels)
|
|
37
|
+
return (
|
|
38
|
+
<span className="cv-p-bubble__status">
|
|
39
|
+
<StatusTicks status={ticksStatus} appearance="stylesheet" />
|
|
40
|
+
<span className={isVisibleText ? 'cv-p-bubble__status-text' : 'cv-p-sr-only'}>{text}</span>
|
|
41
|
+
</span>
|
|
42
|
+
)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
type FailedActionsProps = {
|
|
46
|
+
readonly labels: ParticipantConversationsLabels
|
|
47
|
+
readonly onDiscard?: () => void
|
|
48
|
+
readonly onEdit?: () => void
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function FailedActions({ labels, onDiscard, onEdit }: FailedActionsProps) {
|
|
52
|
+
if (!onDiscard && !onEdit) return null
|
|
53
|
+
return (
|
|
54
|
+
<span className="cv-p-bubble__actions">
|
|
55
|
+
{onEdit ? (
|
|
56
|
+
<button type="button" className="cv-p-bubble__action" onClick={onEdit}>
|
|
57
|
+
{labels.edit}
|
|
58
|
+
</button>
|
|
59
|
+
) : null}
|
|
60
|
+
{onDiscard ? (
|
|
61
|
+
<button type="button" className="cv-p-bubble__action" onClick={onDiscard}>
|
|
62
|
+
{labels.discard}
|
|
63
|
+
</button>
|
|
64
|
+
) : null}
|
|
65
|
+
</span>
|
|
66
|
+
)
|
|
67
|
+
}
|
|
@@ -24,25 +24,33 @@ export type ParticipantChannelBadgesProps = {
|
|
|
24
24
|
readonly labels: ParticipantConversationsLabels
|
|
25
25
|
/** 'list' draws ul/li; 'inline' draws spans only, valid inside a button. */
|
|
26
26
|
readonly variant?: 'list' | 'inline'
|
|
27
|
+
/** Icon size in pixels. */
|
|
28
|
+
readonly iconSize?: number
|
|
27
29
|
}
|
|
28
30
|
|
|
29
31
|
type BadgeProps = {
|
|
30
32
|
readonly descriptor: ParticipantChannelDescriptor
|
|
31
33
|
readonly labels: ParticipantConversationsLabels
|
|
32
34
|
readonly separator: string
|
|
35
|
+
readonly iconSize: number
|
|
33
36
|
}
|
|
34
37
|
|
|
35
|
-
function BadgeContent({ descriptor, labels, separator }: BadgeProps) {
|
|
38
|
+
function BadgeContent({ descriptor, labels, separator, iconSize }: BadgeProps) {
|
|
36
39
|
const Icon = ICONS[descriptor.iconName]
|
|
37
40
|
return (
|
|
38
41
|
<>
|
|
39
|
-
<Icon size={
|
|
42
|
+
<Icon size={iconSize} aria-hidden={true} />
|
|
40
43
|
<span className="cv-p-sr-only">{`${resolveParticipantChannelLabel(descriptor, labels)}${separator}`}</span>
|
|
41
44
|
</>
|
|
42
45
|
)
|
|
43
46
|
}
|
|
44
47
|
|
|
45
|
-
export function ParticipantChannelBadges({
|
|
48
|
+
export function ParticipantChannelBadges({
|
|
49
|
+
channels,
|
|
50
|
+
labels,
|
|
51
|
+
variant = 'list',
|
|
52
|
+
iconSize = 14,
|
|
53
|
+
}: ParticipantChannelBadgesProps) {
|
|
46
54
|
const descriptors = resolveParticipantChannels(channels)
|
|
47
55
|
if (descriptors.length === 0) return null
|
|
48
56
|
|
|
@@ -56,6 +64,7 @@ export function ParticipantChannelBadges({ channels, labels, variant = 'list' }:
|
|
|
56
64
|
descriptor={descriptor}
|
|
57
65
|
labels={labels}
|
|
58
66
|
separator={index < descriptors.length - 1 ? ', ' : ''}
|
|
67
|
+
iconSize={iconSize}
|
|
59
68
|
/>
|
|
60
69
|
</span>
|
|
61
70
|
))}
|
|
@@ -67,7 +76,7 @@ export function ParticipantChannelBadges({ channels, labels, variant = 'list' }:
|
|
|
67
76
|
<ul className="cv-p-channels" aria-label={labels.channelsGroup}>
|
|
68
77
|
{descriptors.map((descriptor) => (
|
|
69
78
|
<li key={descriptor.channel} className={`cv-p-channel cv-p-channel--${descriptor.channel}`}>
|
|
70
|
-
<BadgeContent descriptor={descriptor} labels={labels} separator="" />
|
|
79
|
+
<BadgeContent descriptor={descriptor} labels={labels} separator="" iconSize={iconSize} />
|
|
71
80
|
</li>
|
|
72
81
|
))}
|
|
73
82
|
</ul>
|
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { useState, type ChangeEvent, type FormEvent } from 'react'
|
|
2
2
|
|
|
3
3
|
import { channelCapabilityFor } from '../channelCapability'
|
|
4
4
|
import type { ConversationChannel } from '../conversationChannel'
|
|
5
5
|
import { formatFileSize } from '../lib/format'
|
|
6
6
|
import type { QuickReply } from '../quickReplies/quickReply.types'
|
|
7
|
+
import { AttachControl, MessageField, SendButton } from './ParticipantComposerControls'
|
|
7
8
|
import { maxBytesForChannel, reduceFilesSelection, type ParticipantFileRejection } from './participantFileValidation'
|
|
8
9
|
import type { ParticipantConversationsLabels } from './participantLabels'
|
|
9
10
|
import { applyQuickReply } from './participantQuickReply'
|
|
@@ -73,34 +74,6 @@ function rejectionText(rejection: ParticipantFileRejection, labels: ParticipantC
|
|
|
73
74
|
return rejection === 'tooLarge' ? labels.attachmentTooLarge : labels.attachmentTypeNotAccepted
|
|
74
75
|
}
|
|
75
76
|
|
|
76
|
-
type AttachControlProps = {
|
|
77
|
-
readonly labels: ParticipantConversationsLabels
|
|
78
|
-
readonly disabled: boolean
|
|
79
|
-
readonly acceptedTypes?: readonly string[]
|
|
80
|
-
readonly onChosen: (event: ChangeEvent<HTMLInputElement>) => void
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
function AttachControl({ labels, disabled, acceptedTypes, onChosen }: AttachControlProps) {
|
|
84
|
-
const fileInputRef = useRef<HTMLInputElement>(null)
|
|
85
|
-
return (
|
|
86
|
-
<>
|
|
87
|
-
<input
|
|
88
|
-
ref={fileInputRef}
|
|
89
|
-
className="cv-p-composer__file-input"
|
|
90
|
-
type="file"
|
|
91
|
-
multiple
|
|
92
|
-
hidden
|
|
93
|
-
accept={acceptedTypes?.join(',')}
|
|
94
|
-
onChange={onChosen}
|
|
95
|
-
/>
|
|
96
|
-
<button type="button" className="cv-p-button" disabled={disabled} onClick={() => fileInputRef.current?.click()}>
|
|
97
|
-
<span aria-hidden="true">+</span>
|
|
98
|
-
<span className="cv-p-sr-only">{labels.attach}</span>
|
|
99
|
-
</button>
|
|
100
|
-
</>
|
|
101
|
-
)
|
|
102
|
-
}
|
|
103
|
-
|
|
104
77
|
function useFileSelection(props: ParticipantComposerProps) {
|
|
105
78
|
const [rejection, setRejection] = useState<ParticipantFileRejection | undefined>(undefined)
|
|
106
79
|
const { files, onFilesChange, channel } = props
|
|
@@ -147,20 +120,15 @@ export function ParticipantComposer(props: ParticipantComposerProps) {
|
|
|
147
120
|
{channelCapabilityFor(channel).attachments.accepted ? (
|
|
148
121
|
<AttachControl labels={labels} disabled={disabled} acceptedTypes={props.acceptedTypes} onChosen={handleFilesChosen} />
|
|
149
122
|
) : null}
|
|
150
|
-
<
|
|
151
|
-
className="cv-p-composer__input"
|
|
152
|
-
aria-label={labels.messageInputLabel}
|
|
153
|
-
placeholder={labels.messageInputPlaceholder}
|
|
154
|
-
rows={1}
|
|
123
|
+
<MessageField
|
|
155
124
|
value={value}
|
|
156
|
-
|
|
125
|
+
label={labels.messageInputLabel}
|
|
126
|
+
placeholder={labels.messageInputPlaceholder}
|
|
157
127
|
disabled={disabled}
|
|
158
|
-
|
|
128
|
+
maxLength={props.maxLength}
|
|
129
|
+
onChange={onChange}
|
|
159
130
|
/>
|
|
160
|
-
<
|
|
161
|
-
<span aria-hidden="true">➤</span>
|
|
162
|
-
<span className="cv-p-sr-only">{labels.send}</span>
|
|
163
|
-
</button>
|
|
131
|
+
<SendButton label={labels.send} disabled={!canSend} />
|
|
164
132
|
</div>
|
|
165
133
|
</form>
|
|
166
134
|
)
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { useLayoutEffect, useRef, type ChangeEvent } from 'react'
|
|
2
|
+
|
|
3
|
+
import { Paperclip, Send } from 'lucide-react'
|
|
4
|
+
|
|
5
|
+
import { fitTextareaHeight } from './participantAutoGrow'
|
|
6
|
+
import type { ParticipantConversationsLabels } from './participantLabels'
|
|
7
|
+
|
|
8
|
+
type AttachControlProps = {
|
|
9
|
+
readonly labels: ParticipantConversationsLabels
|
|
10
|
+
readonly disabled: boolean
|
|
11
|
+
readonly acceptedTypes?: readonly string[]
|
|
12
|
+
readonly onChosen: (event: ChangeEvent<HTMLInputElement>) => void
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function AttachControl({ labels, disabled, acceptedTypes, onChosen }: AttachControlProps) {
|
|
16
|
+
const fileInputRef = useRef<HTMLInputElement>(null)
|
|
17
|
+
return (
|
|
18
|
+
<>
|
|
19
|
+
<input
|
|
20
|
+
ref={fileInputRef}
|
|
21
|
+
className="cv-p-composer__file-input"
|
|
22
|
+
type="file"
|
|
23
|
+
multiple
|
|
24
|
+
hidden
|
|
25
|
+
accept={acceptedTypes?.join(',')}
|
|
26
|
+
onChange={onChosen}
|
|
27
|
+
/>
|
|
28
|
+
<button
|
|
29
|
+
type="button"
|
|
30
|
+
className="cv-p-composer__attach"
|
|
31
|
+
aria-label={labels.attach}
|
|
32
|
+
disabled={disabled}
|
|
33
|
+
onClick={() => fileInputRef.current?.click()}
|
|
34
|
+
>
|
|
35
|
+
<Paperclip size={24} aria-hidden="true" focusable="false" />
|
|
36
|
+
</button>
|
|
37
|
+
</>
|
|
38
|
+
)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function SendButton({ label, disabled }: { readonly label: string; readonly disabled: boolean }) {
|
|
42
|
+
return (
|
|
43
|
+
<button
|
|
44
|
+
type="submit"
|
|
45
|
+
className="cv-p-button cv-p-button--primary cv-p-composer__send"
|
|
46
|
+
aria-label={label}
|
|
47
|
+
disabled={disabled}
|
|
48
|
+
>
|
|
49
|
+
<Send size={20} aria-hidden="true" focusable="false" />
|
|
50
|
+
</button>
|
|
51
|
+
)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
type MessageFieldProps = {
|
|
55
|
+
readonly value: string
|
|
56
|
+
readonly label: string
|
|
57
|
+
readonly placeholder: string
|
|
58
|
+
readonly disabled: boolean
|
|
59
|
+
readonly maxLength?: number
|
|
60
|
+
readonly onChange: (value: string) => void
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function MessageField({ value, label, placeholder, disabled, maxLength, onChange }: MessageFieldProps) {
|
|
64
|
+
const fieldRef = useRef<HTMLTextAreaElement>(null)
|
|
65
|
+
|
|
66
|
+
useLayoutEffect(() => {
|
|
67
|
+
if (fieldRef.current) fitTextareaHeight(fieldRef.current)
|
|
68
|
+
}, [value])
|
|
69
|
+
|
|
70
|
+
return (
|
|
71
|
+
<textarea
|
|
72
|
+
ref={fieldRef}
|
|
73
|
+
className="cv-p-composer__input"
|
|
74
|
+
aria-label={label}
|
|
75
|
+
placeholder={placeholder}
|
|
76
|
+
rows={1}
|
|
77
|
+
value={value}
|
|
78
|
+
maxLength={maxLength}
|
|
79
|
+
disabled={disabled}
|
|
80
|
+
onChange={(event) => onChange(event.target.value)}
|
|
81
|
+
/>
|
|
82
|
+
)
|
|
83
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { ReactNode } from 'react'
|
|
2
|
+
|
|
3
|
+
export type ParticipantConversationAvatarProps = {
|
|
4
|
+
readonly icon: ReactNode
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function ParticipantConversationAvatar({ icon }: ParticipantConversationAvatarProps) {
|
|
8
|
+
return (
|
|
9
|
+
<span className="cv-p-thread__avatar" aria-hidden="true">
|
|
10
|
+
{icon}
|
|
11
|
+
</span>
|
|
12
|
+
)
|
|
13
|
+
}
|