@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.
Files changed (93) hide show
  1. package/README.md +169 -0
  2. package/dist/{ConversationSimulatorPanel-DnXElfY8.d.ts → ConversationSimulatorPanel-CrfFjiWh.d.ts} +2 -268
  3. package/dist/{chunk-PJNR6UMN.js → chunk-CBYEHPX6.js} +1 -63
  4. package/dist/{chunk-HMCOTZAX.js → chunk-HOKNKXIT.js} +1 -1
  5. package/dist/{chunk-4P5KMWOI.js → chunk-JXJCPN34.js} +327 -522
  6. package/dist/chunk-MRVFFJEH.js +64 -0
  7. package/dist/chunk-RJMSM3YA.js +249 -0
  8. package/dist/chunk-WSFGNR5R.js +55 -0
  9. package/dist/flows/index.js +5 -3
  10. package/dist/index.d.ts +9 -3
  11. package/dist/index.js +783 -807
  12. package/dist/participant/index.d.ts +162 -0
  13. package/dist/participant/index.js +1448 -0
  14. package/dist/preview/index.d.ts +4 -2
  15. package/dist/preview/index.js +4 -2
  16. package/dist/quickReply.types-B4gmZsSr.d.ts +274 -0
  17. package/dist/styles.css +591 -0
  18. package/package.json +9 -5
  19. package/src/ChannelIcon.tsx +2 -0
  20. package/src/MessageBubble.test.tsx +2 -2
  21. package/src/MessageText.test.tsx +59 -0
  22. package/src/MessageText.tsx +20 -12
  23. package/src/MessageTextCopiedBadge.tsx +7 -0
  24. package/src/StatusTicks.test.tsx +30 -0
  25. package/src/StatusTicks.tsx +24 -9
  26. package/src/buildOutput.test.ts +41 -0
  27. package/src/channelCapability.test.ts +17 -0
  28. package/src/conversationChannel.test.ts +30 -1
  29. package/src/conversationChannel.ts +19 -1
  30. package/src/participant/ParticipantAttachmentItem.tsx +77 -0
  31. package/src/participant/ParticipantComposer.test.tsx +97 -0
  32. package/src/participant/ParticipantComposer.tsx +167 -0
  33. package/src/participant/ParticipantConversations.test.tsx +90 -0
  34. package/src/participant/ParticipantConversations.tsx +42 -0
  35. package/src/participant/ParticipantConversationsScreen.tsx +67 -0
  36. package/src/participant/ParticipantInbox.test.tsx +147 -0
  37. package/src/participant/ParticipantInbox.tsx +168 -0
  38. package/src/participant/ParticipantLoadState.tsx +27 -0
  39. package/src/participant/ParticipantMessageBubble.test.tsx +165 -0
  40. package/src/participant/ParticipantMessageBubble.tsx +185 -0
  41. package/src/participant/ParticipantThread.test.tsx +132 -0
  42. package/src/participant/ParticipantThread.tsx +174 -0
  43. package/src/participant/ParticipantThreadScreen.tsx +135 -0
  44. package/src/participant/bindAttachmentUrlResolver.test.ts +25 -0
  45. package/src/participant/bindAttachmentUrlResolver.ts +7 -0
  46. package/src/participant/index.ts +25 -0
  47. package/src/participant/participant.types.ts +43 -0
  48. package/src/participant/participantApi.types.ts +59 -0
  49. package/src/participant/participantAttachmentUrl.test.ts +32 -0
  50. package/src/participant/participantAttachmentUrl.ts +10 -0
  51. package/src/participant/participantBubbleActions.test.ts +44 -0
  52. package/src/participant/participantBubbleActions.ts +40 -0
  53. package/src/participant/participantDrafts.test.ts +65 -0
  54. package/src/participant/participantDrafts.ts +50 -0
  55. package/src/participant/participantFileValidation.test.ts +86 -0
  56. package/src/participant/participantFileValidation.ts +72 -0
  57. package/src/participant/participantFixtures.test-helper.ts +41 -0
  58. package/src/participant/participantGrouping.test.ts +138 -0
  59. package/src/participant/participantGrouping.ts +77 -0
  60. package/src/participant/participantInboxState.test.ts +69 -0
  61. package/src/participant/participantInboxState.ts +74 -0
  62. package/src/participant/participantLabels.ts +86 -0
  63. package/src/participant/participantLoadView.test.ts +23 -0
  64. package/src/participant/participantLoadView.ts +21 -0
  65. package/src/participant/participantMessages.test.ts +128 -0
  66. package/src/participant/participantMessages.ts +73 -0
  67. package/src/participant/participantNewMessages.test.ts +54 -0
  68. package/src/participant/participantNewMessages.ts +29 -0
  69. package/src/participant/participantQuickReply.test.ts +23 -0
  70. package/src/participant/participantQuickReply.ts +6 -0
  71. package/src/participant/participantScroll.test.ts +69 -0
  72. package/src/participant/participantScroll.ts +43 -0
  73. package/src/participant/participantSendController.test.ts +201 -0
  74. package/src/participant/participantSendController.ts +134 -0
  75. package/src/participant/participantSendStates.ts +31 -0
  76. package/src/participant/participantTheme.ts +20 -0
  77. package/src/participant/participantThreadState.test.ts +75 -0
  78. package/src/participant/participantThreadState.ts +59 -0
  79. package/src/participant/shouldMarkRead.test.ts +24 -0
  80. package/src/participant/shouldMarkRead.ts +16 -0
  81. package/src/participant/useDocumentVisibility.ts +18 -0
  82. package/src/participant/useParticipantConversationView.ts +60 -0
  83. package/src/participant/useParticipantDraft.ts +45 -0
  84. package/src/participant/useParticipantInbox.ts +57 -0
  85. package/src/participant/useParticipantMarkRead.ts +26 -0
  86. package/src/participant/useParticipantRevalidation.ts +36 -0
  87. package/src/participant/useParticipantSend.ts +84 -0
  88. package/src/participant/useParticipantThread.ts +116 -0
  89. package/src/participant/useParticipantThreadScroll.ts +26 -0
  90. package/src/participant/useStickToBottom.ts +77 -0
  91. package/src/preview/ConversationSimulatorPanel.tsx +8 -0
  92. package/src/styles.css +160 -0
  93. package/src/types.ts +3 -1
@@ -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
- onClick={handleCopy}
31
- className="text-[14.2px] leading-[19px] whitespace-pre-wrap break-words select-all [&_strong]:font-bold [&_em]:italic [&_del]:line-through"
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,7 @@
1
+ export interface MessageTextCopiedBadgeProps {
2
+ label?: string
3
+ }
4
+
5
+ export function MessageTextCopiedBadge({ label = 'Copied' }: MessageTextCopiedBadgeProps) {
6
+ return <span className="cv-message-text__copied">{label}</span>
7
+ }
@@ -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
+ })
@@ -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 STATUS_COLOR_CLASS: Record<string, string> = {
9
- sent: 'text-black/40 dark:text-white/40',
10
- delivered: 'text-black/40 dark:text-white/40',
11
- read: 'text-sky-500',
12
- failed: 'text-red-500',
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 colorClass = STATUS_COLOR_CLASS[status] ?? STATUS_COLOR_CLASS.sent
42
+ const modifier = STATUS_MODIFIER[status] ?? 'sent'
43
+ const ariaLabel = modifier === 'queued' ? QUEUED_LABEL : undefined
33
44
 
34
45
  return (
35
- <span className={`cursor-help leading-none flex items-center ${colorClass}`} data-cv-tooltip={title}>
36
- {status === 'failed' ? <AlertTriangle size={11} /> : <Ticks double={status !== 'sent'} />}
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
  }
@@ -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, CHANNEL_FILTER_ALL, channelFiltersFor } from './conversationChannel'
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
- export type ConversationChannel = (typeof CONVERSATION_CHANNEL)[keyof typeof CONVERSATION_CHANNEL]
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
+ }