@adatechnology/conversations-ui 0.4.1 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +169 -0
- package/dist/{ConversationSimulatorPanel-DnXElfY8.d.ts → ConversationSimulatorPanel-CrfFjiWh.d.ts} +2 -268
- package/dist/{chunk-PJNR6UMN.js → chunk-CBYEHPX6.js} +1 -63
- package/dist/{chunk-HMCOTZAX.js → chunk-HOKNKXIT.js} +1 -1
- package/dist/{chunk-4P5KMWOI.js → chunk-JXJCPN34.js} +327 -522
- package/dist/chunk-MRVFFJEH.js +64 -0
- package/dist/chunk-RJMSM3YA.js +249 -0
- package/dist/chunk-WSFGNR5R.js +55 -0
- package/dist/flows/index.js +5 -3
- package/dist/index.d.ts +9 -3
- package/dist/index.js +783 -807
- package/dist/participant/index.d.ts +162 -0
- package/dist/participant/index.js +1448 -0
- package/dist/preview/index.d.ts +4 -2
- package/dist/preview/index.js +4 -2
- package/dist/quickReply.types-B4gmZsSr.d.ts +274 -0
- package/dist/styles.css +591 -0
- package/package.json +9 -5
- package/src/ChannelIcon.tsx +2 -0
- package/src/MessageBubble.test.tsx +2 -2
- package/src/MessageText.test.tsx +59 -0
- package/src/MessageText.tsx +20 -12
- package/src/MessageTextCopiedBadge.tsx +7 -0
- package/src/StatusTicks.test.tsx +30 -0
- package/src/StatusTicks.tsx +24 -9
- package/src/buildOutput.test.ts +41 -0
- package/src/channelCapability.test.ts +17 -0
- package/src/conversationChannel.test.ts +30 -1
- package/src/conversationChannel.ts +19 -1
- package/src/participant/ParticipantAttachmentItem.tsx +77 -0
- package/src/participant/ParticipantComposer.test.tsx +97 -0
- package/src/participant/ParticipantComposer.tsx +167 -0
- package/src/participant/ParticipantConversations.test.tsx +90 -0
- package/src/participant/ParticipantConversations.tsx +42 -0
- package/src/participant/ParticipantConversationsScreen.tsx +67 -0
- package/src/participant/ParticipantInbox.test.tsx +147 -0
- package/src/participant/ParticipantInbox.tsx +168 -0
- package/src/participant/ParticipantLoadState.tsx +27 -0
- package/src/participant/ParticipantMessageBubble.test.tsx +165 -0
- package/src/participant/ParticipantMessageBubble.tsx +185 -0
- package/src/participant/ParticipantThread.test.tsx +132 -0
- package/src/participant/ParticipantThread.tsx +174 -0
- package/src/participant/ParticipantThreadScreen.tsx +135 -0
- package/src/participant/bindAttachmentUrlResolver.test.ts +25 -0
- package/src/participant/bindAttachmentUrlResolver.ts +7 -0
- package/src/participant/index.ts +25 -0
- package/src/participant/participant.types.ts +43 -0
- package/src/participant/participantApi.types.ts +59 -0
- package/src/participant/participantAttachmentUrl.test.ts +32 -0
- package/src/participant/participantAttachmentUrl.ts +10 -0
- package/src/participant/participantBubbleActions.test.ts +44 -0
- package/src/participant/participantBubbleActions.ts +40 -0
- package/src/participant/participantDrafts.test.ts +65 -0
- package/src/participant/participantDrafts.ts +50 -0
- package/src/participant/participantFileValidation.test.ts +86 -0
- package/src/participant/participantFileValidation.ts +72 -0
- package/src/participant/participantFixtures.test-helper.ts +41 -0
- package/src/participant/participantGrouping.test.ts +138 -0
- package/src/participant/participantGrouping.ts +77 -0
- package/src/participant/participantInboxState.test.ts +69 -0
- package/src/participant/participantInboxState.ts +74 -0
- package/src/participant/participantLabels.ts +86 -0
- package/src/participant/participantLoadView.test.ts +23 -0
- package/src/participant/participantLoadView.ts +21 -0
- package/src/participant/participantMessages.test.ts +128 -0
- package/src/participant/participantMessages.ts +73 -0
- package/src/participant/participantNewMessages.test.ts +54 -0
- package/src/participant/participantNewMessages.ts +29 -0
- package/src/participant/participantQuickReply.test.ts +23 -0
- package/src/participant/participantQuickReply.ts +6 -0
- package/src/participant/participantScroll.test.ts +69 -0
- package/src/participant/participantScroll.ts +43 -0
- package/src/participant/participantSendController.test.ts +201 -0
- package/src/participant/participantSendController.ts +134 -0
- package/src/participant/participantSendStates.ts +31 -0
- package/src/participant/participantTheme.ts +20 -0
- package/src/participant/participantThreadState.test.ts +75 -0
- package/src/participant/participantThreadState.ts +59 -0
- package/src/participant/shouldMarkRead.test.ts +24 -0
- package/src/participant/shouldMarkRead.ts +16 -0
- package/src/participant/useDocumentVisibility.ts +18 -0
- package/src/participant/useParticipantConversationView.ts +60 -0
- package/src/participant/useParticipantDraft.ts +45 -0
- package/src/participant/useParticipantInbox.ts +57 -0
- package/src/participant/useParticipantMarkRead.ts +26 -0
- package/src/participant/useParticipantRevalidation.ts +36 -0
- package/src/participant/useParticipantSend.ts +84 -0
- package/src/participant/useParticipantThread.ts +116 -0
- package/src/participant/useParticipantThreadScroll.ts +26 -0
- package/src/participant/useStickToBottom.ts +77 -0
- package/src/preview/ConversationSimulatorPanel.tsx +8 -0
- package/src/styles.css +160 -0
- package/src/types.ts +3 -1
package/src/MessageText.tsx
CHANGED
|
@@ -1,18 +1,22 @@
|
|
|
1
|
-
import { useCallback, useState } from 'react'
|
|
1
|
+
import { useCallback, useState, type KeyboardEvent } from 'react'
|
|
2
2
|
import { CHAT_TEXT_SECONDARY_CLASS } from './theme'
|
|
3
3
|
import type { MessagePayload } from './types'
|
|
4
|
-
import { useConversations } from './providers/ConversationsProvider'
|
|
5
4
|
import { parseWhatsAppFormatting } from './lib/whatsapp-formatting'
|
|
5
|
+
import { MessageTextCopiedBadge } from './MessageTextCopiedBadge'
|
|
6
6
|
|
|
7
7
|
export interface MessageTextProps {
|
|
8
8
|
message: MessagePayload
|
|
9
|
+
/** Defaults to true (tap copies the text, as in 0.3.1); false disables copying and its button semantics. */
|
|
10
|
+
copyOnClick?: boolean
|
|
11
|
+
/** Text of the badge shown after copying; absent means the package default ("Copied"). */
|
|
12
|
+
copiedLabel?: string
|
|
9
13
|
}
|
|
10
14
|
|
|
11
|
-
export function MessageText({ message }: MessageTextProps) {
|
|
15
|
+
export function MessageText({ message, copyOnClick = true, copiedLabel }: MessageTextProps) {
|
|
12
16
|
const [copied, setCopied] = useState(false)
|
|
13
17
|
|
|
14
18
|
const handleCopy = useCallback(async () => {
|
|
15
|
-
if (!message.content) return
|
|
19
|
+
if (!copyOnClick || !message.content) return
|
|
16
20
|
try {
|
|
17
21
|
await navigator.clipboard.writeText(message.content)
|
|
18
22
|
setCopied(true)
|
|
@@ -20,22 +24,26 @@ export function MessageText({ message }: MessageTextProps) {
|
|
|
20
24
|
} catch {
|
|
21
25
|
// Clipboard not available
|
|
22
26
|
}
|
|
23
|
-
}, [message.content])
|
|
27
|
+
}, [copyOnClick, message.content])
|
|
28
|
+
|
|
29
|
+
const handleKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
|
|
30
|
+
if (event.key !== 'Enter' && event.key !== ' ') return
|
|
31
|
+
event.preventDefault()
|
|
32
|
+
void handleCopy()
|
|
33
|
+
}
|
|
24
34
|
|
|
25
35
|
if (message.type === 'template')
|
|
26
36
|
return <p className={`text-sm italic ${CHAT_TEXT_SECONDARY_CLASS}`}>{message.content ?? 'Template message'}</p>
|
|
27
37
|
|
|
28
38
|
return (
|
|
29
39
|
<div
|
|
30
|
-
|
|
31
|
-
|
|
40
|
+
className={copyOnClick ? 'cv-message-text cv-message-text--copyable' : 'cv-message-text'}
|
|
41
|
+
{...(copyOnClick
|
|
42
|
+
? { role: 'button', tabIndex: 0, onClick: handleCopy, onKeyDown: handleKeyDown }
|
|
43
|
+
: {})}
|
|
32
44
|
>
|
|
33
45
|
<div>{parseWhatsAppFormatting(message.content ?? '')}</div>
|
|
34
|
-
{copied &&
|
|
35
|
-
<span className="absolute top-0 right-0 -translate-y-full bg-[#3b4a54] text-white text-[11px] px-1.5 py-0.5 rounded shadow-lg">
|
|
36
|
-
Copiado!
|
|
37
|
-
</span>
|
|
38
|
-
)}
|
|
46
|
+
{copied && <MessageTextCopiedBadge label={copiedLabel} />}
|
|
39
47
|
</div>
|
|
40
48
|
)
|
|
41
49
|
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test'
|
|
2
|
+
import { renderToStaticMarkup } from 'react-dom/server'
|
|
3
|
+
|
|
4
|
+
import { StatusTicks } from './StatusTicks'
|
|
5
|
+
|
|
6
|
+
const TAILWIND_UTILITIES = ['text-sky-500', 'text-red-500', 'text-black/40', 'flex', 'cursor-help', 'leading-none']
|
|
7
|
+
|
|
8
|
+
describe('StatusTicks', () => {
|
|
9
|
+
it('renderiza o estado queued com relógio e rótulo acessível', () => {
|
|
10
|
+
const markup = renderToStaticMarkup(<StatusTicks status="queued" />)
|
|
11
|
+
|
|
12
|
+
expect(markup).toContain('cv-status-ticks--queued')
|
|
13
|
+
expect(markup).toContain('aria-label="Queued"')
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
it('trata bounced como failed', () => {
|
|
17
|
+
const markup = renderToStaticMarkup(<StatusTicks status="bounced" />)
|
|
18
|
+
|
|
19
|
+
expect(markup).toContain('cv-status-ticks--failed')
|
|
20
|
+
expect(markup).not.toContain('cv-status-ticks--bounced')
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it.each(['sent', 'delivered', 'read', 'failed', 'queued'])('usa só classes cv-* em %s', (status) => {
|
|
24
|
+
const markup = renderToStaticMarkup(<StatusTicks status={status} />)
|
|
25
|
+
|
|
26
|
+
expect(markup).toContain(`cv-status-ticks--${status}`)
|
|
27
|
+
for (const utility of TAILWIND_UTILITIES) expect(markup).not.toContain(utility)
|
|
28
|
+
expect(markup).not.toContain('text-[')
|
|
29
|
+
})
|
|
30
|
+
})
|
package/src/StatusTicks.tsx
CHANGED
|
@@ -1,17 +1,21 @@
|
|
|
1
|
-
import { AlertTriangle } from 'lucide-react'
|
|
1
|
+
import { AlertTriangle, Clock } from 'lucide-react'
|
|
2
2
|
|
|
3
3
|
export interface StatusTicksProps {
|
|
4
4
|
status: string
|
|
5
5
|
title?: string
|
|
6
6
|
}
|
|
7
7
|
|
|
8
|
-
const
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
8
|
+
const STATUS_MODIFIER: Record<string, string> = {
|
|
9
|
+
queued: 'queued',
|
|
10
|
+
sent: 'sent',
|
|
11
|
+
delivered: 'delivered',
|
|
12
|
+
read: 'read',
|
|
13
|
+
failed: 'failed',
|
|
14
|
+
bounced: 'failed',
|
|
13
15
|
}
|
|
14
16
|
|
|
17
|
+
const QUEUED_LABEL = 'Queued'
|
|
18
|
+
|
|
15
19
|
function Ticks({ double }: { double: boolean }) {
|
|
16
20
|
return (
|
|
17
21
|
<svg viewBox="0 0 20 12" width="15" height="9" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
@@ -28,12 +32,23 @@ function Ticks({ double }: { double: boolean }) {
|
|
|
28
32
|
|
|
29
33
|
// Paridade com financiamento-imobiliario-bot/apps/web/src/components/MessageBubble.tsx —
|
|
30
34
|
// mesmo path de SVG, mesma cor por status (read → sky-500, failed → red-500 com AlertTriangle).
|
|
35
|
+
function StatusIcon({ modifier }: { modifier: string }) {
|
|
36
|
+
if (modifier === 'failed') return <AlertTriangle size={11} />
|
|
37
|
+
if (modifier === 'queued') return <Clock size={11} />
|
|
38
|
+
return <Ticks double={modifier !== 'sent'} />
|
|
39
|
+
}
|
|
40
|
+
|
|
31
41
|
export function StatusTicks({ status, title }: StatusTicksProps) {
|
|
32
|
-
const
|
|
42
|
+
const modifier = STATUS_MODIFIER[status] ?? 'sent'
|
|
43
|
+
const ariaLabel = modifier === 'queued' ? QUEUED_LABEL : undefined
|
|
33
44
|
|
|
34
45
|
return (
|
|
35
|
-
<span
|
|
36
|
-
{status
|
|
46
|
+
<span
|
|
47
|
+
className={`cv-status-ticks cv-status-ticks--${modifier}`}
|
|
48
|
+
data-cv-tooltip={title ?? ariaLabel}
|
|
49
|
+
aria-label={ariaLabel}
|
|
50
|
+
>
|
|
51
|
+
<StatusIcon modifier={modifier} />
|
|
37
52
|
</span>
|
|
38
53
|
)
|
|
39
54
|
}
|
package/src/buildOutput.test.ts
CHANGED
|
@@ -77,3 +77,44 @@ describe('o bundle do host não paga por @xyflow/react sem pedir', () => {
|
|
|
77
77
|
expect(await distText('index.js')).not.toContain('@xyflow/react')
|
|
78
78
|
})
|
|
79
79
|
})
|
|
80
|
+
|
|
81
|
+
describe('a visão do participante é leve e isolada', () => {
|
|
82
|
+
const PARTICIPANT_ENTRY_LIMIT_BYTES = 250 * 1024
|
|
83
|
+
|
|
84
|
+
async function participantBundle(): Promise<string> {
|
|
85
|
+
// O entry mais os chunks relativos que ele importa, transitivamente: é o que o host baixa.
|
|
86
|
+
const seen = new Set<string>()
|
|
87
|
+
const queue = ['participant/index.js']
|
|
88
|
+
let total = ''
|
|
89
|
+
while (queue.length > 0) {
|
|
90
|
+
const file = queue.shift() as string
|
|
91
|
+
if (seen.has(file)) continue
|
|
92
|
+
seen.add(file)
|
|
93
|
+
const text = await distText(file)
|
|
94
|
+
total += text
|
|
95
|
+
for (const match of text.matchAll(/(?:from|import)\s*"(\.[^"]+\.js)"/g)) {
|
|
96
|
+
const target = new URL(match[1] as string, `file:///${file}`).pathname.slice(1)
|
|
97
|
+
queue.push(target)
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return total
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
it('não puxa o xyflow nem o ConversationsWorkspace', async () => {
|
|
104
|
+
const bundle = await participantBundle()
|
|
105
|
+
|
|
106
|
+
expect(bundle).not.toContain('xyflow')
|
|
107
|
+
expect(bundle).not.toContain('ConversationsWorkspace')
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
it('fica abaixo de 250 KB com os chunks que importa', async () => {
|
|
111
|
+
const bundle = await participantBundle()
|
|
112
|
+
|
|
113
|
+
expect(new TextEncoder().encode(bundle).length).toBeLessThan(PARTICIPANT_ENTRY_LIMIT_BYTES)
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
it('entrega a tela e os tipos no subpath /participant', async () => {
|
|
117
|
+
expect(await distText('participant/index.js')).toContain('ParticipantConversations')
|
|
118
|
+
expect(await distText('participant/index.d.ts')).toContain('ParticipantConversations')
|
|
119
|
+
})
|
|
120
|
+
})
|
|
@@ -45,3 +45,20 @@ describe('linhas da capacidade que a tela não pode fingir', () => {
|
|
|
45
45
|
expect(CORE_CHANNEL_CAPABILITIES.portal.audio).toEqual({ plays: true, records: false })
|
|
46
46
|
})
|
|
47
47
|
})
|
|
48
|
+
|
|
49
|
+
// App e portal são canais do contrato que a UI passou a exibir (T1.0a): a capacidade vem do
|
|
50
|
+
// contrato, nunca da regra do WhatsApp, que daria janela de 24h e 100 MB onde não existem.
|
|
51
|
+
describe('canais app e portal', () => {
|
|
52
|
+
it('app: anexo de 25 MB, grava áudio e não tem janela de sessão', () => {
|
|
53
|
+
const capability = channelCapabilityFor(CONVERSATION_CHANNEL.APP)
|
|
54
|
+
|
|
55
|
+
expect(capability).toBe(CORE_CHANNEL_CAPABILITIES.app)
|
|
56
|
+
expect(capability.attachments.maxBytes).toBe(25 * 1024 * 1024)
|
|
57
|
+
expect(capability.audio.records).toBe(true)
|
|
58
|
+
expect(capability.sessionWindowHours).toBeNull()
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it('portal: ouve áudio e não grava', () => {
|
|
62
|
+
expect(channelCapabilityFor(CONVERSATION_CHANNEL.PORTAL).audio.records).toBe(false)
|
|
63
|
+
})
|
|
64
|
+
})
|
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
import { describe, expect, it } from 'bun:test'
|
|
2
|
-
import { CONVERSATION_CHANNEL
|
|
2
|
+
import { CONVERSATION_CHANNEL as CORE_CONVERSATION_CHANNEL } from '@adatechnology/conversation-contracts'
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
CHANNEL_CAPABILITIES,
|
|
6
|
+
CHANNEL_FILTER_ALL,
|
|
7
|
+
CONVERSATION_CHANNEL,
|
|
8
|
+
HANDLE_KIND,
|
|
9
|
+
REOPEN_MECHANISM,
|
|
10
|
+
channelFiltersFor,
|
|
11
|
+
type ConversationChannel,
|
|
12
|
+
} from './conversationChannel'
|
|
3
13
|
|
|
4
14
|
describe('channelFiltersFor', () => {
|
|
5
15
|
// O ponto do helper: a barra oferece só o que existe. Oferecer Instagram numa conta que só tem
|
|
@@ -51,3 +61,22 @@ describe('channelFiltersFor', () => {
|
|
|
51
61
|
expect(first).toEqual(second)
|
|
52
62
|
})
|
|
53
63
|
})
|
|
64
|
+
|
|
65
|
+
describe('canais do contrato exibidos pela UI', () => {
|
|
66
|
+
// Canal novo no contrato sem entrada aqui reprova no tsc (Record completo); este teste cobre o
|
|
67
|
+
// mesmo ponto em runtime, para o caso de o vocabulário ser alterado sem recompilar a UI.
|
|
68
|
+
it('todo canal do contrato tem entrada de exibição', () => {
|
|
69
|
+
for (const channel of CORE_CONVERSATION_CHANNEL) {
|
|
70
|
+
expect(CHANNEL_CAPABILITIES[channel as ConversationChannel]).toBeDefined()
|
|
71
|
+
}
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it('app e portal existem como canais, com rótulo e sem reabertura por template ou tag', () => {
|
|
75
|
+
expect(CONVERSATION_CHANNEL.APP).toBe('app')
|
|
76
|
+
expect(CONVERSATION_CHANNEL.PORTAL).toBe('portal')
|
|
77
|
+
expect(CHANNEL_CAPABILITIES.app.reopenMechanism).toBe(REOPEN_MECHANISM.NONE)
|
|
78
|
+
expect(CHANNEL_CAPABILITIES.portal.reopenMechanism).toBe(REOPEN_MECHANISM.NONE)
|
|
79
|
+
expect(CHANNEL_CAPABILITIES.app.handleKind).toBe(HANDLE_KIND.SESSION)
|
|
80
|
+
expect(CHANNEL_CAPABILITIES.portal.handleKind).toBe(HANDLE_KIND.SESSION)
|
|
81
|
+
})
|
|
82
|
+
})
|
|
@@ -10,16 +10,22 @@
|
|
|
10
10
|
* funcionando exatamente como antes.
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
+
import type { ConversationChannel as CoreConversationChannel } from '@adatechnology/conversation-contracts'
|
|
14
|
+
|
|
13
15
|
import { formatPhone, phoneCountryFlag } from './lib/phone'
|
|
14
16
|
|
|
15
17
|
export const CONVERSATION_CHANNEL = {
|
|
16
18
|
WHATSAPP: 'whatsapp',
|
|
17
19
|
MESSENGER: 'messenger',
|
|
18
20
|
INSTAGRAM: 'instagram',
|
|
21
|
+
APP: 'app',
|
|
22
|
+
PORTAL: 'portal',
|
|
19
23
|
WEBCHAT: 'webchat',
|
|
20
24
|
EMAIL: 'email',
|
|
21
25
|
} as const
|
|
22
|
-
|
|
26
|
+
|
|
27
|
+
/** O que o contrato conhece, mais os dois canais da Meta que a UI já servia antes dele. */
|
|
28
|
+
export type ConversationChannel = CoreConversationChannel | 'messenger' | 'instagram'
|
|
23
29
|
|
|
24
30
|
export const DEFAULT_CONVERSATION_CHANNEL: ConversationChannel = CONVERSATION_CHANNEL.WHATSAPP
|
|
25
31
|
|
|
@@ -75,6 +81,18 @@ export const CHANNEL_CAPABILITIES: Readonly<Record<ConversationChannel, ChannelC
|
|
|
75
81
|
reopenMechanism: REOPEN_MECHANISM.TAG,
|
|
76
82
|
handleKind: HANDLE_KIND.USERNAME,
|
|
77
83
|
},
|
|
84
|
+
[CONVERSATION_CHANNEL.APP]: {
|
|
85
|
+
label: 'App',
|
|
86
|
+
icon: '📱',
|
|
87
|
+
reopenMechanism: REOPEN_MECHANISM.NONE,
|
|
88
|
+
handleKind: HANDLE_KIND.SESSION,
|
|
89
|
+
},
|
|
90
|
+
[CONVERSATION_CHANNEL.PORTAL]: {
|
|
91
|
+
label: 'Portal',
|
|
92
|
+
icon: '🔗',
|
|
93
|
+
reopenMechanism: REOPEN_MECHANISM.NONE,
|
|
94
|
+
handleKind: HANDLE_KIND.SESSION,
|
|
95
|
+
},
|
|
78
96
|
[CONVERSATION_CHANNEL.WEBCHAT]: {
|
|
79
97
|
// Chat próprio: sem intermediário, sem janela. Bloquear o composer aqui seria inventar limite.
|
|
80
98
|
label: 'Chat do site',
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react'
|
|
2
|
+
|
|
3
|
+
import type { ParticipantAttachment } from '@adatechnology/conversation-contracts'
|
|
4
|
+
|
|
5
|
+
import { formatFileSize } from '../lib/format'
|
|
6
|
+
import { isSafeAttachmentUrl } from './participantAttachmentUrl'
|
|
7
|
+
import type { ParticipantConversationsLabels } from './participantLabels'
|
|
8
|
+
|
|
9
|
+
export type ResolveParticipantAttachmentUrl = (
|
|
10
|
+
attachment: ParticipantAttachment,
|
|
11
|
+
disposition?: 'inline' | 'attachment',
|
|
12
|
+
) => Promise<string>
|
|
13
|
+
|
|
14
|
+
export type ParticipantAttachmentItemProps = {
|
|
15
|
+
readonly attachment: ParticipantAttachment
|
|
16
|
+
readonly resolveAttachmentUrl: ResolveParticipantAttachmentUrl
|
|
17
|
+
readonly labels: ParticipantConversationsLabels
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function useAttachmentUrl(
|
|
21
|
+
attachment: ParticipantAttachment,
|
|
22
|
+
resolveAttachmentUrl: ResolveParticipantAttachmentUrl,
|
|
23
|
+
): string | undefined {
|
|
24
|
+
const [url, setUrl] = useState<string | undefined>(undefined)
|
|
25
|
+
const disposition = attachment.kind === 'document' ? 'attachment' : 'inline'
|
|
26
|
+
|
|
27
|
+
useEffect(() => {
|
|
28
|
+
let isCurrent = true
|
|
29
|
+
resolveAttachmentUrl(attachment, disposition).then(
|
|
30
|
+
(resolved) => {
|
|
31
|
+
if (isCurrent) setUrl(isSafeAttachmentUrl(resolved) ? resolved.trim() : undefined)
|
|
32
|
+
},
|
|
33
|
+
() => {
|
|
34
|
+
if (isCurrent) setUrl(undefined)
|
|
35
|
+
},
|
|
36
|
+
)
|
|
37
|
+
return () => {
|
|
38
|
+
isCurrent = false
|
|
39
|
+
}
|
|
40
|
+
}, [attachment, disposition, resolveAttachmentUrl])
|
|
41
|
+
|
|
42
|
+
return url
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function ParticipantAttachmentItem({ attachment, resolveAttachmentUrl, labels }: ParticipantAttachmentItemProps) {
|
|
46
|
+
const url = useAttachmentUrl(attachment, resolveAttachmentUrl)
|
|
47
|
+
|
|
48
|
+
if (attachment.kind === 'image') {
|
|
49
|
+
return (
|
|
50
|
+
<figure className="cv-p-attachment cv-p-attachment--image">
|
|
51
|
+
{url ? <img className="cv-p-attachment__image" src={url} alt={attachment.filename} /> : null}
|
|
52
|
+
<figcaption className="cv-p-attachment__name">{attachment.filename}</figcaption>
|
|
53
|
+
</figure>
|
|
54
|
+
)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (attachment.kind === 'audio') {
|
|
58
|
+
return (
|
|
59
|
+
<div className="cv-p-attachment cv-p-attachment--audio">
|
|
60
|
+
<span className="cv-p-attachment__name">{attachment.filename}</span>
|
|
61
|
+
<audio className="cv-p-attachment__audio" controls preload="none" src={url} />
|
|
62
|
+
</div>
|
|
63
|
+
)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return (
|
|
67
|
+
<a
|
|
68
|
+
className="cv-p-attachment cv-p-attachment--document"
|
|
69
|
+
href={url}
|
|
70
|
+
download={attachment.filename}
|
|
71
|
+
aria-label={`${labels.downloadAttachment}: ${attachment.filename}`}
|
|
72
|
+
>
|
|
73
|
+
<span className="cv-p-attachment__name">{attachment.filename}</span>
|
|
74
|
+
<span className="cv-p-attachment__size">{formatFileSize(attachment.sizeBytes)}</span>
|
|
75
|
+
</a>
|
|
76
|
+
)
|
|
77
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test'
|
|
2
|
+
import { renderToStaticMarkup } from 'react-dom/server'
|
|
3
|
+
|
|
4
|
+
import type { QuickReply } from '../quickReplies/quickReply.types'
|
|
5
|
+
import { findForeignClassTokens, findUtilityClassTokens } from './participantFixtures.test-helper'
|
|
6
|
+
import { DEFAULT_PARTICIPANT_CONVERSATIONS_LABELS } from './participantLabels'
|
|
7
|
+
import { ParticipantComposer, type ParticipantComposerProps } from './ParticipantComposer'
|
|
8
|
+
|
|
9
|
+
const QUICK_REPLIES: readonly QuickReply[] = [
|
|
10
|
+
{ id: '1', title: 'On my way', shortcut: 'way', body: 'I am on my way' },
|
|
11
|
+
{ id: '2', title: 'Delivered', shortcut: 'done', body: 'Delivered' },
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
function render(overrides: Partial<ParticipantComposerProps> = {}): string {
|
|
15
|
+
return renderToStaticMarkup(
|
|
16
|
+
<ParticipantComposer
|
|
17
|
+
value=""
|
|
18
|
+
onChange={() => undefined}
|
|
19
|
+
files={[]}
|
|
20
|
+
onFilesChange={() => undefined}
|
|
21
|
+
onSend={() => undefined}
|
|
22
|
+
labels={DEFAULT_PARTICIPANT_CONVERSATIONS_LABELS}
|
|
23
|
+
{...overrides}
|
|
24
|
+
/>,
|
|
25
|
+
)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
describe('ParticipantComposer', () => {
|
|
29
|
+
it('renders a form with a labelled textarea and the maxLength prop', () => {
|
|
30
|
+
const markup = render({ maxLength: 500 })
|
|
31
|
+
|
|
32
|
+
expect(markup).toContain('<form')
|
|
33
|
+
expect(markup).toContain('<textarea')
|
|
34
|
+
expect(markup).toContain('maxLength="500"')
|
|
35
|
+
expect(markup).toContain('aria-label="Message"')
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('disables send without content and enables it with text or files', () => {
|
|
39
|
+
expect(render()).toMatch(/<button[^>]*type="submit"[^>]*disabled/)
|
|
40
|
+
expect(render({ value: 'hi' })).not.toMatch(/<button[^>]*type="submit"[^>]*disabled/)
|
|
41
|
+
const file = new File(['x'], 'a.png', { type: 'image/png' })
|
|
42
|
+
expect(render({ files: [file] })).not.toMatch(/<button[^>]*type="submit"[^>]*disabled/)
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('disables send when the composer is disabled even with text', () => {
|
|
46
|
+
expect(render({ value: 'hi', disabled: true })).toMatch(/<button[^>]*type="submit"[^>]*disabled/)
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('renders the hidden multiple file input with accept and the attach button', () => {
|
|
50
|
+
const markup = render({ acceptedTypes: ['image/*'] })
|
|
51
|
+
|
|
52
|
+
expect(markup).toContain('type="file"')
|
|
53
|
+
expect(markup).toContain('multiple')
|
|
54
|
+
expect(markup).toContain('accept="image/*"')
|
|
55
|
+
expect(markup).toContain('Attach')
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('lists chosen files with a remove button', () => {
|
|
59
|
+
const file = new File(['x'], 'photo.png', { type: 'image/png' })
|
|
60
|
+
const markup = render({ files: [file] })
|
|
61
|
+
|
|
62
|
+
expect(markup).toContain('photo.png')
|
|
63
|
+
expect(markup).toContain('Remove attachment')
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it('renders quick reply chips only when quickReplies is non-empty', () => {
|
|
67
|
+
expect(render()).not.toContain('cv-p-quick')
|
|
68
|
+
expect(render({ quickReplies: [] })).not.toContain('cv-p-quick')
|
|
69
|
+
const markup = render({ quickReplies: QUICK_REPLIES })
|
|
70
|
+
expect(markup).toContain('cv-p-quick')
|
|
71
|
+
expect(markup).toContain('On my way')
|
|
72
|
+
expect(markup).toContain('type="button"')
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('renders chips as type=button so tapping one never submits the form', () => {
|
|
76
|
+
const markup = render({ quickReplies: QUICK_REPLIES })
|
|
77
|
+
const chips = [...markup.matchAll(/<button[^>]*class="cv-p-chip"[^>]*>/g)].map((match) => match[0])
|
|
78
|
+
|
|
79
|
+
expect(chips).toHaveLength(2)
|
|
80
|
+
for (const chip of chips) {
|
|
81
|
+
expect(chip).toContain('type="button"')
|
|
82
|
+
expect(chip).not.toContain('type="submit"')
|
|
83
|
+
}
|
|
84
|
+
expect(markup.match(/type="submit"/g)).toHaveLength(1)
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
it('has no microphone control', () => {
|
|
88
|
+
expect(render({ quickReplies: QUICK_REPLIES }).toLowerCase()).not.toMatch(/microphone|record/)
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
it('uses only cv-p-* classes and no Tailwind utilities', () => {
|
|
92
|
+
const markup = render({ quickReplies: QUICK_REPLIES, value: 'x' })
|
|
93
|
+
|
|
94
|
+
expect(findUtilityClassTokens(markup)).toEqual([])
|
|
95
|
+
expect(findForeignClassTokens(markup)).toEqual([])
|
|
96
|
+
})
|
|
97
|
+
})
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { useRef, useState, type ChangeEvent, type FormEvent } from 'react'
|
|
2
|
+
|
|
3
|
+
import { channelCapabilityFor } from '../channelCapability'
|
|
4
|
+
import type { ConversationChannel } from '../conversationChannel'
|
|
5
|
+
import { formatFileSize } from '../lib/format'
|
|
6
|
+
import type { QuickReply } from '../quickReplies/quickReply.types'
|
|
7
|
+
import { maxBytesForChannel, reduceFilesSelection, type ParticipantFileRejection } from './participantFileValidation'
|
|
8
|
+
import type { ParticipantConversationsLabels } from './participantLabels'
|
|
9
|
+
import { applyQuickReply } from './participantQuickReply'
|
|
10
|
+
|
|
11
|
+
export type ParticipantComposerProps = {
|
|
12
|
+
readonly value: string
|
|
13
|
+
readonly onChange: (value: string) => void
|
|
14
|
+
readonly files: readonly File[]
|
|
15
|
+
readonly onFilesChange: (files: readonly File[]) => void
|
|
16
|
+
readonly onSend: () => void
|
|
17
|
+
readonly labels: ParticipantConversationsLabels
|
|
18
|
+
/** Absent means the `app` channel. */
|
|
19
|
+
readonly channel?: ConversationChannel
|
|
20
|
+
readonly disabled?: boolean
|
|
21
|
+
/** Blocks only the send button: the field stays editable while a send is in flight. */
|
|
22
|
+
readonly isSending?: boolean
|
|
23
|
+
readonly maxLength?: number
|
|
24
|
+
/** MIME types or wildcards offered to the file picker and enforced before sending. */
|
|
25
|
+
readonly acceptedTypes?: readonly string[]
|
|
26
|
+
/** Tapping a chip fills the text; it never sends. Absent or empty means no strip. */
|
|
27
|
+
readonly quickReplies?: readonly QuickReply[]
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
type QuickReplyStripProps = {
|
|
31
|
+
readonly quickReplies: readonly QuickReply[]
|
|
32
|
+
readonly labels: ParticipantConversationsLabels
|
|
33
|
+
readonly onPick: (body: string) => void
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function QuickReplyStrip({ quickReplies, labels, onPick }: QuickReplyStripProps) {
|
|
37
|
+
return (
|
|
38
|
+
<div className="cv-p-quick" role="group" aria-label={labels.quickRepliesGroup}>
|
|
39
|
+
{quickReplies.map((quickReply) => (
|
|
40
|
+
<button key={quickReply.id} type="button" className="cv-p-chip" onClick={() => onPick(quickReply.body)}>
|
|
41
|
+
{quickReply.title}
|
|
42
|
+
</button>
|
|
43
|
+
))}
|
|
44
|
+
</div>
|
|
45
|
+
)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
type FileListProps = {
|
|
49
|
+
readonly files: readonly File[]
|
|
50
|
+
readonly labels: ParticipantConversationsLabels
|
|
51
|
+
readonly onRemove: (file: File) => void
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function FileList({ files, labels, onRemove }: FileListProps) {
|
|
55
|
+
if (files.length === 0) return null
|
|
56
|
+
return (
|
|
57
|
+
<ul className="cv-p-files" aria-label={labels.attachmentsList}>
|
|
58
|
+
{files.map((file, index) => (
|
|
59
|
+
<li key={`${file.name}-${file.size}-${index}`} className="cv-p-files__item">
|
|
60
|
+
<span className="cv-p-files__name">{file.name}</span>
|
|
61
|
+
<span className="cv-p-files__size">{formatFileSize(file.size)}</span>
|
|
62
|
+
<button type="button" className="cv-p-files__remove" aria-label={`${labels.removeAttachment}: ${file.name}`} onClick={() => onRemove(file)}>
|
|
63
|
+
<span aria-hidden="true">×</span>
|
|
64
|
+
<span className="cv-p-sr-only">{labels.removeAttachment}</span>
|
|
65
|
+
</button>
|
|
66
|
+
</li>
|
|
67
|
+
))}
|
|
68
|
+
</ul>
|
|
69
|
+
)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function rejectionText(rejection: ParticipantFileRejection, labels: ParticipantConversationsLabels): string {
|
|
73
|
+
return rejection === 'tooLarge' ? labels.attachmentTooLarge : labels.attachmentTypeNotAccepted
|
|
74
|
+
}
|
|
75
|
+
|
|
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
|
+
function useFileSelection(props: ParticipantComposerProps) {
|
|
105
|
+
const [rejection, setRejection] = useState<ParticipantFileRejection | undefined>(undefined)
|
|
106
|
+
const { files, onFilesChange, channel } = props
|
|
107
|
+
const maxBytes = maxBytesForChannel(channel)
|
|
108
|
+
|
|
109
|
+
function handleFilesChosen(event: ChangeEvent<HTMLInputElement>): void {
|
|
110
|
+
const chosen = Array.from(event.target.files ?? [])
|
|
111
|
+
event.target.value = ''
|
|
112
|
+
const result = reduceFilesSelection({ current: files, incoming: chosen, maxBytes, acceptedTypes: props.acceptedTypes })
|
|
113
|
+
setRejection(result.rejected[0]?.reason)
|
|
114
|
+
if (result.files.length !== files.length) onFilesChange(result.files)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function handleRemoveFile(file: File): void {
|
|
118
|
+
setRejection(undefined)
|
|
119
|
+
onFilesChange(files.filter((current) => current !== file))
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return { rejection, handleFilesChosen, handleRemoveFile }
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function ParticipantComposer(props: ParticipantComposerProps) {
|
|
126
|
+
const { value, onChange, files, onSend, labels, channel = 'app', disabled = false, isSending = false } = props
|
|
127
|
+
const { rejection, handleFilesChosen, handleRemoveFile } = useFileSelection(props)
|
|
128
|
+
const canSend = !disabled && !isSending && (value.trim().length > 0 || files.length > 0)
|
|
129
|
+
|
|
130
|
+
function handleSubmit(event: FormEvent<HTMLFormElement>): void {
|
|
131
|
+
event.preventDefault()
|
|
132
|
+
if (canSend) onSend()
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return (
|
|
136
|
+
<form className="cv-p-composer" onSubmit={handleSubmit}>
|
|
137
|
+
{props.quickReplies && props.quickReplies.length > 0 ? (
|
|
138
|
+
<QuickReplyStrip quickReplies={props.quickReplies} labels={labels} onPick={(body) => onChange(applyQuickReply(value, body))} />
|
|
139
|
+
) : null}
|
|
140
|
+
<FileList files={files} labels={labels} onRemove={handleRemoveFile} />
|
|
141
|
+
{rejection ? (
|
|
142
|
+
<p className="cv-p-composer__error" role="alert">
|
|
143
|
+
{rejectionText(rejection, labels)}
|
|
144
|
+
</p>
|
|
145
|
+
) : null}
|
|
146
|
+
<div className="cv-p-composer__row">
|
|
147
|
+
{channelCapabilityFor(channel).attachments.accepted ? (
|
|
148
|
+
<AttachControl labels={labels} disabled={disabled} acceptedTypes={props.acceptedTypes} onChosen={handleFilesChosen} />
|
|
149
|
+
) : null}
|
|
150
|
+
<textarea
|
|
151
|
+
className="cv-p-composer__input"
|
|
152
|
+
aria-label={labels.messageInputLabel}
|
|
153
|
+
placeholder={labels.messageInputPlaceholder}
|
|
154
|
+
rows={1}
|
|
155
|
+
value={value}
|
|
156
|
+
maxLength={props.maxLength}
|
|
157
|
+
disabled={disabled}
|
|
158
|
+
onChange={(event) => onChange(event.target.value)}
|
|
159
|
+
/>
|
|
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>
|
|
164
|
+
</div>
|
|
165
|
+
</form>
|
|
166
|
+
)
|
|
167
|
+
}
|