@adatechnology/conversations-ui 0.6.1 → 0.7.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 +126 -19
- 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.js +834 -764
- package/dist/participant/index.d.ts +18 -1
- package/dist/participant/index.js +1188 -313
- package/dist/preview/index.js +2 -3
- package/dist/styles.css +583 -19
- package/package.json +3 -3
- 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 +25 -81
- 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 +45 -95
- package/src/participant/ParticipantThreadAvatarSequence.test.tsx +65 -0
- package/src/participant/ParticipantThreadHeader.test.tsx +181 -0
- package/src/participant/ParticipantThreadHeader.tsx +73 -0
- package/src/participant/ParticipantThreadScreen.tsx +24 -10
- package/src/participant/ParticipantTimeline.tsx +103 -0
- package/src/participant/index.ts +1 -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/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/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/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/useParticipantCopyFeedback.ts +33 -0
- package/src/participant/useParticipantThreadScroll.ts +10 -2
- 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 +137 -21
- 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.7.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,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
|
+
}
|
|
@@ -5,6 +5,7 @@ import type { ParticipantConversationSummary, ParticipantSubjectRef } from '@ada
|
|
|
5
5
|
import type { ConversationChannel } from '../conversationChannel'
|
|
6
6
|
import type { QuickReply } from '../quickReplies/quickReply.types'
|
|
7
7
|
import type { ConversationsTheme } from '../types'
|
|
8
|
+
import type { ParticipantAuthorAvatarRenderer } from './ParticipantAuthorAvatar'
|
|
8
9
|
import { ParticipantConversationsScreen } from './ParticipantConversationsScreen'
|
|
9
10
|
import type {
|
|
10
11
|
ParticipantConversationsClassNames,
|
|
@@ -34,6 +35,12 @@ export type ParticipantConversationsProps = {
|
|
|
34
35
|
renderSubjectIcon?: ParticipantSubjectIconRenderer
|
|
35
36
|
renderSubjectCard?: (conversation: ParticipantConversationSummary) => ReactNode
|
|
36
37
|
onOpenSubject?: (subject: ParticipantSubjectRef) => void
|
|
38
|
+
/** Absent draws no avatar. The SDK has no photo: the host supplies it through renderAuthorAvatar. */
|
|
39
|
+
avatars?: 'initials'
|
|
40
|
+
/** Host slot for the author's photo; wins over 'initials' and enables avatars on its own. */
|
|
41
|
+
renderAuthorAvatar?: ParticipantAuthorAvatarRenderer
|
|
42
|
+
/** Speech-bubble tail on the bottom corner; false removes it. Default true. */
|
|
43
|
+
tail?: boolean
|
|
37
44
|
}
|
|
38
45
|
|
|
39
46
|
export function ParticipantConversations(props: ParticipantConversationsProps) {
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { afterAll, beforeAll, describe, expect, it, mock } from 'bun:test'
|
|
2
|
+
import { renderToStaticMarkup } from 'react-dom/server'
|
|
3
|
+
|
|
4
|
+
import type { ParticipantConversationsApi } from './participantApi.types'
|
|
5
|
+
import { buildConversation, buildMessage } from './participantFixtures.test-helper'
|
|
6
|
+
import type { ParticipantConversationsScreenProps } from './ParticipantConversationsScreen'
|
|
7
|
+
import * as realThreadModule from './useParticipantThread'
|
|
8
|
+
|
|
9
|
+
const MESSAGES = [
|
|
10
|
+
buildMessage({ id: 'a', authorName: 'Ana Souza', createdAt: '2026-10-01T10:00:00.000Z' }),
|
|
11
|
+
buildMessage({ id: 'b', direction: 'inbound', createdAt: '2026-10-01T10:01:00.000Z' }),
|
|
12
|
+
]
|
|
13
|
+
const realUseParticipantThread = realThreadModule.useParticipantThread
|
|
14
|
+
let isInjecting = false
|
|
15
|
+
|
|
16
|
+
const API: ParticipantConversationsApi = {
|
|
17
|
+
listConversations: async () => ({ data: [] }),
|
|
18
|
+
fetchMessages: async () => [],
|
|
19
|
+
sendMessage: async () => ({ outcome: 'queued' }),
|
|
20
|
+
markRead: async () => undefined,
|
|
21
|
+
resolveAttachmentUrl: async () => 'https://files.example/a',
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
let ParticipantConversationsScreen: typeof import('./ParticipantConversationsScreen').ParticipantConversationsScreen
|
|
25
|
+
|
|
26
|
+
beforeAll(async () => {
|
|
27
|
+
// Server rendering runs no effects, so the messages are injected over the real hook.
|
|
28
|
+
mock.module('./useParticipantThread', () => ({
|
|
29
|
+
...realThreadModule,
|
|
30
|
+
useParticipantThread: (...args: Parameters<typeof realUseParticipantThread>) => {
|
|
31
|
+
const result = realUseParticipantThread(...args)
|
|
32
|
+
return isInjecting ? { ...result, messages: MESSAGES, status: 'ready' as const } : result
|
|
33
|
+
},
|
|
34
|
+
}))
|
|
35
|
+
ParticipantConversationsScreen = (await import('./ParticipantConversationsScreen')).ParticipantConversationsScreen
|
|
36
|
+
isInjecting = true
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
afterAll(() => {
|
|
40
|
+
isInjecting = false
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
function renderThread(overrides: Partial<ParticipantConversationsScreenProps> = {}): string {
|
|
44
|
+
return renderToStaticMarkup(
|
|
45
|
+
<ParticipantConversationsScreen
|
|
46
|
+
api={API}
|
|
47
|
+
subjectGroups={[{ subjectType: 'invoice', label: 'Invoices' }]}
|
|
48
|
+
selected={{ subjectType: 'invoice', subjectId: '1' }}
|
|
49
|
+
onSelect={() => undefined}
|
|
50
|
+
inbox={{
|
|
51
|
+
status: 'ready',
|
|
52
|
+
conversations: [buildConversation({ subjectId: '1', subjectLabel: 'Invoice 4521' })],
|
|
53
|
+
hasMore: false,
|
|
54
|
+
refresh: async () => undefined,
|
|
55
|
+
loadMore: async () => undefined,
|
|
56
|
+
markSubjectRead: () => undefined,
|
|
57
|
+
}}
|
|
58
|
+
{...overrides}
|
|
59
|
+
/>,
|
|
60
|
+
)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
describe('participant options reach the DOM through the screen', () => {
|
|
64
|
+
it('injects the messages (sanity)', () => {
|
|
65
|
+
expect(renderThread()).toContain('cv-p-bubble--mine')
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('draws the group eyebrow from subjectGroups', () => {
|
|
69
|
+
expect(renderThread()).toContain('<p class="cv-p-thread__eyebrow">Invoices</p>')
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('draws no avatar by default and the initials with avatars="initials"', () => {
|
|
73
|
+
expect(renderThread()).not.toContain('cv-p-avatar')
|
|
74
|
+
expect(renderThread({ avatars: 'initials' })).toContain('class="cv-p-avatar" aria-hidden="true">AS<')
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it('draws the host slot through renderAuthorAvatar', () => {
|
|
78
|
+
const markup = renderThread({ renderAuthorAvatar: () => <i>PHOTO</i> })
|
|
79
|
+
expect(markup).toContain('<i>PHOTO</i>')
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it('keeps the tail by default and removes it from every bubble with tail={false}', () => {
|
|
83
|
+
expect(renderThread()).not.toContain('cv-p-bubble--no-tail')
|
|
84
|
+
const markup = renderThread({ tail: false })
|
|
85
|
+
expect(markup.match(/cv-p-bubble--no-tail/g)?.length).toBe(2)
|
|
86
|
+
})
|
|
87
|
+
})
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { createContext, useCallback, useContext, useEffect, useReducer, useRef, type ReactNode } from 'react'
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
INITIAL_COPY_ANNOUNCEMENT,
|
|
5
|
+
copyAnnouncementReducer,
|
|
6
|
+
resolveLiveText,
|
|
7
|
+
type CopyAnnouncementState,
|
|
8
|
+
} from './participantCopyAnnouncement'
|
|
9
|
+
|
|
10
|
+
const ANNOUNCEMENT_MS = 3000
|
|
11
|
+
|
|
12
|
+
const AnnounceContext = createContext<(text: string) => void>(() => undefined)
|
|
13
|
+
const AnnouncementContext = createContext<CopyAnnouncementState>(INITIAL_COPY_ANNOUNCEMENT)
|
|
14
|
+
|
|
15
|
+
export function useAnnounceCopy(): (text: string) => void {
|
|
16
|
+
return useContext(AnnounceContext)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Holds the text of the conversation's single polite region; the copy buttons announce through it. */
|
|
20
|
+
export function ParticipantCopyAnnouncer({ children }: { readonly children: ReactNode }) {
|
|
21
|
+
const [announcement, dispatch] = useReducer(copyAnnouncementReducer, INITIAL_COPY_ANNOUNCEMENT)
|
|
22
|
+
const announce = useCallback((text: string): void => dispatch({ type: 'announce', text }), [])
|
|
23
|
+
|
|
24
|
+
useEffect(() => {
|
|
25
|
+
if (announcement.text === '') return undefined
|
|
26
|
+
const timer = setTimeout(() => dispatch({ type: 'expired' }), ANNOUNCEMENT_MS)
|
|
27
|
+
return () => clearTimeout(timer)
|
|
28
|
+
}, [announcement.tick, announcement.text])
|
|
29
|
+
|
|
30
|
+
return (
|
|
31
|
+
<AnnounceContext.Provider value={announce}>
|
|
32
|
+
<AnnouncementContext.Provider value={announcement}>{children}</AnnouncementContext.Provider>
|
|
33
|
+
</AnnounceContext.Provider>
|
|
34
|
+
)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** The one aria-live region of the thread: new-message notice, or the last copy announcement. */
|
|
38
|
+
export function ParticipantThreadLive({ notice }: { readonly notice: string }) {
|
|
39
|
+
const copy = useContext(AnnouncementContext)
|
|
40
|
+
const baselineNotice = useRef<string | undefined>(undefined)
|
|
41
|
+
const resolution = resolveLiveText({ copy, notice, baselineNotice: baselineNotice.current })
|
|
42
|
+
baselineNotice.current = resolution.baselineNotice
|
|
43
|
+
return (
|
|
44
|
+
<div className="cv-p-thread__live" role="status" aria-live="polite">
|
|
45
|
+
{resolution.text}
|
|
46
|
+
</div>
|
|
47
|
+
)
|
|
48
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { Check, Copy } from 'lucide-react'
|
|
2
|
+
|
|
3
|
+
import { useParticipantCopyFeedback } from './useParticipantCopyFeedback'
|
|
4
|
+
import type { CopyableKind } from './participantMessageFormat.types'
|
|
5
|
+
import type { ParticipantConversationsLabels } from './participantLabels'
|
|
6
|
+
|
|
7
|
+
type CopyLabels = Pick<ParticipantConversationsLabels, 'copyValue' | 'valueCopied'>
|
|
8
|
+
|
|
9
|
+
export type ParticipantCopyableTokenProps = {
|
|
10
|
+
readonly copyKind: CopyableKind
|
|
11
|
+
readonly value: string
|
|
12
|
+
readonly labels: CopyLabels
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function ParticipantCopyableToken({ copyKind, value, labels }: ParticipantCopyableTokenProps) {
|
|
16
|
+
const { isCopied, copy } = useParticipantCopyFeedback()
|
|
17
|
+
return (
|
|
18
|
+
<button
|
|
19
|
+
type="button"
|
|
20
|
+
className={`cv-p-copyable cv-p-copyable--${copyKind}`}
|
|
21
|
+
aria-label={`${labels.copyValue} ${value}`}
|
|
22
|
+
onClick={() => copy(value, labels.valueCopied)}
|
|
23
|
+
>
|
|
24
|
+
{value}
|
|
25
|
+
{isCopied ? (
|
|
26
|
+
<span className="cv-p-copyable__done" aria-hidden="true">
|
|
27
|
+
<Check size={12} aria-hidden={true} /> {labels.valueCopied}
|
|
28
|
+
</span>
|
|
29
|
+
) : null}
|
|
30
|
+
</button>
|
|
31
|
+
)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export type ParticipantLinkCopyButtonProps = {
|
|
35
|
+
readonly value: string
|
|
36
|
+
readonly labels: CopyLabels
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function ParticipantLinkCopyButton({ value, labels }: ParticipantLinkCopyButtonProps) {
|
|
40
|
+
const { isCopied, copy } = useParticipantCopyFeedback()
|
|
41
|
+
return (
|
|
42
|
+
<button
|
|
43
|
+
type="button"
|
|
44
|
+
className="cv-p-text__link-copy"
|
|
45
|
+
aria-label={`${labels.copyValue} ${value}`}
|
|
46
|
+
onClick={() => copy(value, labels.valueCopied)}
|
|
47
|
+
>
|
|
48
|
+
{isCopied ? <Check size={14} aria-hidden={true} /> : <Copy size={14} aria-hidden={true} />}
|
|
49
|
+
</button>
|
|
50
|
+
)
|
|
51
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { toDateTimeAttribute } from './participantDay'
|
|
2
|
+
|
|
3
|
+
export type ParticipantDayDividerProps = {
|
|
4
|
+
readonly day: Date
|
|
5
|
+
readonly locale?: string
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function ParticipantDayDivider({ day, locale }: ParticipantDayDividerProps) {
|
|
9
|
+
const label = day.toLocaleDateString(locale, { weekday: 'short', day: '2-digit', month: '2-digit' })
|
|
10
|
+
|
|
11
|
+
return (
|
|
12
|
+
<div className="cv-p-day" role="separator" aria-label={label}>
|
|
13
|
+
<time className="cv-p-day__label" dateTime={toDateTimeAttribute(day)}>
|
|
14
|
+
{label}
|
|
15
|
+
</time>
|
|
16
|
+
</div>
|
|
17
|
+
)
|
|
18
|
+
}
|
|
@@ -12,6 +12,7 @@ import { resolveLoadView, type ParticipantLoadStatus } from './participantLoadVi
|
|
|
12
12
|
import { ParticipantInboxRow } from './ParticipantInboxRow'
|
|
13
13
|
import { ParticipantInboxSearchField, type ParticipantInboxSearch } from './ParticipantInboxSearchField'
|
|
14
14
|
import { ParticipantLoadError, ParticipantLoading } from './ParticipantLoadState'
|
|
15
|
+
import { useRevealActiveChip } from './useRevealActiveChip'
|
|
15
16
|
|
|
16
17
|
export type ParticipantInboxProps = {
|
|
17
18
|
readonly view: ParticipantInboxView
|
|
@@ -54,8 +55,9 @@ type FiltersProps = {
|
|
|
54
55
|
}
|
|
55
56
|
|
|
56
57
|
function Filters({ filters, active, labels, onChange }: FiltersProps) {
|
|
58
|
+
const strip = useRevealActiveChip(active)
|
|
57
59
|
return (
|
|
58
|
-
<div className="cv-p-filters" role="group" aria-label={labels.filtersGroup}>
|
|
60
|
+
<div className="cv-p-filters" role="group" aria-label={labels.filtersGroup} ref={strip}>
|
|
59
61
|
{filters.map((filter) => (
|
|
60
62
|
<button
|
|
61
63
|
key={filter.subjectType}
|
|
@@ -4,6 +4,7 @@ import { formatTimestamp, isSameDay } from '../lib/format'
|
|
|
4
4
|
import type { ParticipantSubjectGroup, ParticipantSubjectIconRenderer } from './participant.types'
|
|
5
5
|
import { formatParticipantLabel, type ParticipantConversationsLabels } from './participantLabels'
|
|
6
6
|
import { ParticipantChannelBadges } from './ParticipantChannelBadges'
|
|
7
|
+
import { isDrawable, resolveConversationIcon } from './participantSubjectIcon'
|
|
7
8
|
|
|
8
9
|
export type ParticipantInboxRowProps = {
|
|
9
10
|
readonly conversation: ParticipantConversationSummary
|
|
@@ -21,17 +22,18 @@ function formatRowTime(iso: string, locale: string | undefined): string {
|
|
|
21
22
|
}
|
|
22
23
|
|
|
23
24
|
function RowIcon({ props, kind }: { readonly props: ParticipantInboxRowProps; readonly kind: string }) {
|
|
24
|
-
const
|
|
25
|
-
|
|
25
|
+
const { conversation, group, renderSubjectIcon } = props
|
|
26
|
+
const hostIcon = renderSubjectIcon?.(conversation)
|
|
27
|
+
if (isDrawable(hostIcon)) {
|
|
26
28
|
return (
|
|
27
29
|
<span className="cv-p-row__icon cv-p-inbox__icon" aria-hidden="true">
|
|
28
|
-
{
|
|
30
|
+
{hostIcon}
|
|
29
31
|
</span>
|
|
30
32
|
)
|
|
31
33
|
}
|
|
32
34
|
return (
|
|
33
35
|
<span className="cv-p-row__icon" aria-hidden="true">
|
|
34
|
-
{
|
|
36
|
+
{resolveConversationIcon({ conversation, group }) ?? kind.slice(0, 2)}
|
|
35
37
|
</span>
|
|
36
38
|
)
|
|
37
39
|
}
|