@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.
Files changed (119) hide show
  1. package/README.md +221 -19
  2. package/dist/{ConversationSimulatorPanel-CrfFjiWh.d.ts → ConversationSimulatorPanel-q-wVgBCN.d.ts} +1 -1
  3. package/dist/{chunk-HOKNKXIT.js → chunk-HMCOTZAX.js} +1 -1
  4. package/dist/{chunk-FAQLC3TX.js → chunk-L3OFAQPS.js} +2 -4
  5. package/dist/{chunk-CBYEHPX6.js → chunk-PJNR6UMN.js} +63 -1
  6. package/dist/flows/index.js +3 -5
  7. package/dist/index.d.ts +4 -4
  8. package/dist/index.js +834 -764
  9. package/dist/participant/index.d.ts +62 -3
  10. package/dist/participant/index.js +1444 -394
  11. package/dist/preview/index.d.ts +3 -3
  12. package/dist/preview/index.js +2 -3
  13. package/dist/{quickReply.types-B4gmZsSr.d.ts → quickReply.types-DzWH3NYV.d.ts} +1 -1
  14. package/dist/styles.css +629 -20
  15. package/package.json +3 -3
  16. package/src/participant/ConversationThread.test.tsx +165 -0
  17. package/src/participant/ConversationThread.tsx +117 -0
  18. package/src/participant/ParticipantAuthorAvatar.test.tsx +30 -0
  19. package/src/participant/ParticipantAuthorAvatar.tsx +24 -0
  20. package/src/participant/ParticipantBackButton.tsx +14 -0
  21. package/src/participant/ParticipantBubbleStatus.tsx +67 -0
  22. package/src/participant/ParticipantChannelBadges.tsx +13 -4
  23. package/src/participant/ParticipantComposer.tsx +8 -40
  24. package/src/participant/ParticipantComposerControls.tsx +83 -0
  25. package/src/participant/ParticipantConversationAvatar.tsx +13 -0
  26. package/src/participant/ParticipantConversations.tsx +7 -0
  27. package/src/participant/ParticipantConversationsOptions.test.tsx +87 -0
  28. package/src/participant/ParticipantCopyAnnouncer.tsx +48 -0
  29. package/src/participant/ParticipantCopyableToken.tsx +51 -0
  30. package/src/participant/ParticipantDayDivider.tsx +18 -0
  31. package/src/participant/ParticipantInbox.tsx +3 -1
  32. package/src/participant/ParticipantInboxRow.tsx +6 -4
  33. package/src/participant/ParticipantMessageBubble.test.tsx +52 -5
  34. package/src/participant/ParticipantMessageBubble.tsx +32 -84
  35. package/src/participant/ParticipantMessageNodes.tsx +54 -0
  36. package/src/participant/ParticipantMessageText.test.tsx +106 -0
  37. package/src/participant/ParticipantMessageText.tsx +52 -0
  38. package/src/participant/ParticipantProtocolBadge.tsx +15 -7
  39. package/src/participant/ParticipantProtocolView.test.tsx +4 -6
  40. package/src/participant/ParticipantScrollToLatest.test.tsx +153 -0
  41. package/src/participant/ParticipantScrollToLatest.tsx +40 -0
  42. package/src/participant/ParticipantThread.test.tsx +37 -0
  43. package/src/participant/ParticipantThread.tsx +54 -95
  44. package/src/participant/ParticipantThreadAvatarSequence.test.tsx +65 -0
  45. package/src/participant/ParticipantThreadHeader.test.tsx +181 -0
  46. package/src/participant/ParticipantThreadHeader.tsx +83 -0
  47. package/src/participant/ParticipantThreadScreen.tsx +30 -56
  48. package/src/participant/ParticipantTimeline.tsx +106 -0
  49. package/src/participant/conversationThreadApi.test.ts +27 -0
  50. package/src/participant/conversationThreadApi.ts +24 -0
  51. package/src/participant/conversationThreadCss.test.ts +116 -0
  52. package/src/participant/conversationThreadSummary.ts +34 -0
  53. package/src/participant/index.ts +4 -0
  54. package/src/participant/participantAutoGrow.test.ts +43 -0
  55. package/src/participant/participantAutoGrow.ts +12 -0
  56. package/src/participant/participantAvatar.test.ts +74 -0
  57. package/src/participant/participantAvatar.ts +48 -0
  58. package/src/participant/participantBubbleTail.test.tsx +107 -0
  59. package/src/participant/participantChatDesign.test.tsx +198 -0
  60. package/src/participant/participantContrast.test-helper.ts +116 -0
  61. package/src/participant/participantCopyAnnouncement.test.ts +52 -0
  62. package/src/participant/participantCopyAnnouncement.ts +37 -0
  63. package/src/participant/participantCss.test-helper.ts +34 -0
  64. package/src/participant/participantDay.test.ts +22 -0
  65. package/src/participant/participantDay.ts +8 -0
  66. package/src/participant/participantDocuments.ts +45 -0
  67. package/src/participant/participantGolden.test.tsx +46 -0
  68. package/src/participant/participantGoldenScenarios.test-helper.tsx +57 -0
  69. package/src/participant/participantHeaderA11y.test.tsx +85 -0
  70. package/src/participant/participantHeaderDesign.test.tsx +111 -0
  71. package/src/participant/participantLabels.ts +11 -0
  72. package/src/participant/participantMessageAtoms.ts +53 -0
  73. package/src/participant/participantMessageCopyables.test.ts +141 -0
  74. package/src/participant/participantMessageCopyables.ts +76 -0
  75. package/src/participant/participantMessageFormat.test-helper.ts +21 -0
  76. package/src/participant/participantMessageFormat.test.ts +177 -0
  77. package/src/participant/participantMessageFormat.ts +135 -0
  78. package/src/participant/participantMessageFormat.types.ts +22 -0
  79. package/src/participant/participantMessageInline.test.ts +82 -0
  80. package/src/participant/participantMessageInline.ts +99 -0
  81. package/src/participant/participantMessageLinks.test.ts +100 -0
  82. package/src/participant/participantMessageLinks.ts +54 -0
  83. package/src/participant/participantMessageTextStyle.test.ts +137 -0
  84. package/src/participant/participantMessages.ts +4 -3
  85. package/src/participant/participantNewMessages.test.ts +21 -0
  86. package/src/participant/participantNewMessages.ts +8 -3
  87. package/src/participant/participantPerspective.test.ts +67 -0
  88. package/src/participant/participantPerspective.ts +50 -0
  89. package/src/participant/participantPerspectiveRender.test.tsx +94 -0
  90. package/src/participant/participantScrollFocus.test.ts +22 -0
  91. package/src/participant/participantScrollFocus.ts +10 -0
  92. package/src/participant/participantScrollView.test.ts +74 -0
  93. package/src/participant/participantScrollView.ts +38 -0
  94. package/src/participant/participantScrollbars.test.ts +156 -0
  95. package/src/participant/participantSubjectIcon.test.tsx +27 -0
  96. package/src/participant/participantSubjectIcon.ts +26 -0
  97. package/src/participant/participantThemeContrast.test.ts +98 -0
  98. package/src/participant/test-fixtures/conversations-list.golden.html +1 -0
  99. package/src/participant/test-fixtures/conversations-selected.golden.html +1 -0
  100. package/src/participant/test-fixtures/thread-avatars-initials.golden.html +1 -0
  101. package/src/participant/test-fixtures/thread-closed.golden.html +1 -0
  102. package/src/participant/test-fixtures/thread-default.golden.html +1 -0
  103. package/src/participant/test-fixtures/thread-error.golden.html +1 -0
  104. package/src/participant/test-fixtures/thread-loading.golden.html +1 -0
  105. package/src/participant/test-fixtures/thread-plain.golden.html +1 -0
  106. package/src/participant/test-fixtures/thread-tail-off.golden.html +1 -0
  107. package/src/participant/useParticipantConversationView.ts +3 -0
  108. package/src/participant/useParticipantCopyFeedback.ts +33 -0
  109. package/src/participant/useParticipantThread.ts +13 -2
  110. package/src/participant/useParticipantThreadController.ts +112 -0
  111. package/src/participant/useParticipantThreadScroll.ts +13 -3
  112. package/src/participant/useParticipantUnread.test.tsx +40 -0
  113. package/src/participant/useParticipantUnread.ts +38 -0
  114. package/src/participant/useRevealActiveChip.ts +20 -0
  115. package/src/participant/useScrollAwayState.ts +24 -0
  116. package/src/participant/useStickToBottom.ts +16 -2
  117. package/src/styles.css +151 -23
  118. package/dist/chunk-MRVFFJEH.js +0 -64
  119. 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.6.1",
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/meta-whatsapp-contracts": "0.6.0",
39
- "@adatechnology/conversation-contracts": "0.5.0"
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={14} aria-hidden={true} />
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({ channels, labels, variant = 'list' }: ParticipantChannelBadgesProps) {
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 { useRef, useState, type ChangeEvent, type FormEvent } from 'react'
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
- <textarea
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
- maxLength={props.maxLength}
125
+ label={labels.messageInputLabel}
126
+ placeholder={labels.messageInputPlaceholder}
157
127
  disabled={disabled}
158
- onChange={(event) => onChange(event.target.value)}
128
+ maxLength={props.maxLength}
129
+ onChange={onChange}
159
130
  />
160
- <button type="submit" className="cv-p-button cv-p-button--primary" disabled={!canSend}>
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
+ }