@adatechnology/conversations-ui 0.1.0-rc.6 → 0.1.0-rc.7

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.
@@ -0,0 +1,126 @@
1
+ /**
2
+ * Menu interativo do WhatsApp — botões de resposta rápida e lista de opções.
3
+ *
4
+ * A mensagem é gravada com o payload cru que foi enviado à Meta, então a UI apenas o desenha; não
5
+ * há segunda fonte de verdade sobre quais opções existiam naquele momento. Sem isto, mensagem
6
+ * `interactive` aparecia como texto solto e o menu simplesmente sumia da conversa — inclusive no
7
+ * simulador, onde o operador precisa ver exatamente o que o cliente vê.
8
+ *
9
+ * `onSelect` é opcional de propósito: no histórico da inbox as opções são leitura (o operador não
10
+ * responde no lugar do cliente); no simulador elas são clicáveis.
11
+ */
12
+
13
+ import { useState } from 'react'
14
+ import type { InteractiveOption, InteractivePayload, InteractiveSelection } from './types'
15
+ import { cn } from './lib/cn'
16
+
17
+ export interface InteractiveMessageLabels {
18
+ /** Fallback do rótulo do botão que abre a lista, quando o payload não traz um. */
19
+ openList: string
20
+ }
21
+
22
+ export const DEFAULT_INTERACTIVE_MESSAGE_LABELS: InteractiveMessageLabels = {
23
+ openList: 'Ver opções',
24
+ }
25
+
26
+ export interface InteractiveMessageProps {
27
+ payload: InteractivePayload
28
+ onSelect?: (selection: InteractiveSelection) => void
29
+ labels?: Partial<InteractiveMessageLabels>
30
+ className?: string
31
+ }
32
+
33
+ function collectRows(payload: InteractivePayload): InteractiveOption[] {
34
+ return (payload.action?.sections ?? []).flatMap((section) => section.rows ?? [])
35
+ }
36
+
37
+ function collectButtons(payload: InteractivePayload): InteractiveOption[] {
38
+ return (payload.action?.buttons ?? [])
39
+ .map((button) => button.reply)
40
+ .filter((reply): reply is InteractiveOption => reply !== undefined)
41
+ }
42
+
43
+ export function InteractiveMessage({ payload, onSelect, labels, className }: InteractiveMessageProps) {
44
+ const openListLabel = labels?.openList ?? DEFAULT_INTERACTIVE_MESSAGE_LABELS.openList
45
+ const [isListOpen, setIsListOpen] = useState(false)
46
+
47
+ const buttons = collectButtons(payload)
48
+ const sections = payload.action?.sections ?? []
49
+ const hasRows = collectRows(payload).length > 0
50
+ const isInteractable = onSelect !== undefined
51
+
52
+ return (
53
+ <div className={cn('flex flex-col gap-1', className)}>
54
+ {payload.header?.text ? (
55
+ <p className="text-sm font-semibold text-gray-900 dark:text-gray-100">{payload.header.text}</p>
56
+ ) : null}
57
+
58
+ {payload.body?.text ? (
59
+ <p className="whitespace-pre-wrap break-words text-sm text-gray-900 dark:text-gray-100">
60
+ {payload.body.text}
61
+ </p>
62
+ ) : null}
63
+
64
+ {payload.footer?.text ? (
65
+ <p className="text-xs text-gray-500 dark:text-gray-400">{payload.footer.text}</p>
66
+ ) : null}
67
+
68
+ {buttons.length > 0 ? (
69
+ <div className="mt-1 flex flex-col gap-1 border-t border-gray-200 pt-1 dark:border-gray-700">
70
+ {buttons.map((button) => (
71
+ <button
72
+ key={button.id}
73
+ type="button"
74
+ disabled={!isInteractable}
75
+ onClick={() => onSelect?.({ kind: 'button', option: button })}
76
+ className="rounded-md px-3 py-1.5 text-sm font-medium text-teal-700 transition-colors enabled:hover:bg-teal-50 disabled:cursor-default dark:text-teal-300 dark:enabled:hover:bg-teal-900/30"
77
+ >
78
+ {button.title}
79
+ </button>
80
+ ))}
81
+ </div>
82
+ ) : null}
83
+
84
+ {hasRows ? (
85
+ <div className="mt-1 border-t border-gray-200 pt-1 dark:border-gray-700">
86
+ <button
87
+ type="button"
88
+ onClick={() => setIsListOpen((open) => !open)}
89
+ aria-expanded={isListOpen}
90
+ className="w-full rounded-md px-3 py-1.5 text-sm font-medium text-teal-700 transition-colors hover:bg-teal-50 dark:text-teal-300 dark:hover:bg-teal-900/30"
91
+ >
92
+ ☰ {payload.action?.button ?? openListLabel}
93
+ </button>
94
+
95
+ {isListOpen ? (
96
+ <div className="mt-1 flex flex-col gap-1">
97
+ {sections.map((section, sectionIndex) => (
98
+ <div key={section.title ?? sectionIndex} className="flex flex-col">
99
+ {section.title ? (
100
+ <p className="px-3 py-1 text-xs font-semibold uppercase text-gray-500 dark:text-gray-400">
101
+ {section.title}
102
+ </p>
103
+ ) : null}
104
+ {(section.rows ?? []).map((row) => (
105
+ <button
106
+ key={row.id}
107
+ type="button"
108
+ disabled={!isInteractable}
109
+ onClick={() => onSelect?.({ kind: 'list', option: row })}
110
+ className="rounded-md px-3 py-1.5 text-left text-sm text-gray-900 transition-colors enabled:hover:bg-gray-100 disabled:cursor-default dark:text-gray-100 dark:enabled:hover:bg-gray-700"
111
+ >
112
+ <span className="block">{row.title}</span>
113
+ {row.description ? (
114
+ <span className="block text-xs text-gray-500 dark:text-gray-400">{row.description}</span>
115
+ ) : null}
116
+ </button>
117
+ ))}
118
+ </div>
119
+ ))}
120
+ </div>
121
+ ) : null}
122
+ </div>
123
+ ) : null}
124
+ </div>
125
+ )
126
+ }
@@ -1,10 +1,11 @@
1
1
  import { useMemo, useState } from 'react'
2
2
  import { Check } from 'lucide-react'
3
- import type { MessagePayload } from './types'
3
+ import type { InteractiveSelection, MessagePayload } from './types'
4
4
  import { useConversationLocales } from './ConversationLocalesProvider'
5
5
  import { StatusTicks } from './StatusTicks'
6
6
  import { MediaRenderer, type ResolveMediaUrl } from './MediaRenderer'
7
7
  import { Lightbox } from './Lightbox'
8
+ import { InteractiveMessage } from './InteractiveMessage'
8
9
  import { parseWhatsAppFormatting } from './lib/whatsapp-formatting'
9
10
  import { cn } from './lib/cn'
10
11
  import { createMediaUrlResolver } from './lib/createMediaUrlResolver'
@@ -24,6 +25,11 @@ export interface MessageBubbleProps {
24
25
  * `ConversationsProvider` — passe apenas para sobrescrever (cache próprio, CDN, proxy do host).
25
26
  */
26
27
  onResolveMediaUrl?: ResolveMediaUrl
28
+ /**
29
+ * Toque numa opção do menu interativo. Ausente, as opções aparecem desabilitadas — que é o certo
30
+ * no histórico da inbox: o operador vê o que foi oferecido, sem responder no lugar do cliente.
31
+ */
32
+ onInteractiveSelect?: (selection: InteractiveSelection) => void
27
33
  className?: string
28
34
  }
29
35
 
@@ -45,7 +51,7 @@ const MEDIA_TYPES = new Set(['image', 'audio', 'video', 'document', 'sticker'])
45
51
  // tailwind.config do host expondo as cores `whatsapp.*` — ver Wallpaper.tsx e T6.2.
46
52
  export function MessageBubble({
47
53
  message, isMine, senderName, isFirstInGroup = true, isSelecting = false, isSelected = false, onToggleSelect,
48
- onResolveMediaUrl, className,
54
+ onResolveMediaUrl, onInteractiveSelect, className,
49
55
  }: MessageBubbleProps) {
50
56
  const { bubble, selection } = useConversationLocales()
51
57
  const [lightboxSrc, setLightboxSrc] = useState<string | null>(null)
@@ -64,6 +70,7 @@ export function MessageBubble({
64
70
  const hasError = message.status === 'failed'
65
71
  const isMedia = MEDIA_TYPES.has(message.type)
66
72
  const isTemplate = message.type === 'template'
73
+ const isInteractive = message.type === 'interactive'
67
74
  const displayName = message.sender === 'agent' && senderName ? senderName : bubble[message.sender] ?? message.sender
68
75
 
69
76
  const tooltipText = message.status === 'read' && message.readAt
@@ -132,6 +139,10 @@ export function MessageBubble({
132
139
 
133
140
  {isMedia ? (
134
141
  <MediaRenderer message={message} onLightbox={setLightboxSrc} onResolveUrl={resolveMediaUrl} />
142
+ ) : isInteractive && message.payload ? (
143
+ // O texto da mensagem interativa mora dentro do payload (`body.text`), e `content` guarda
144
+ // só uma cópia achatada para busca — renderizar `content` aqui duplicaria o corpo.
145
+ <InteractiveMessage payload={message.payload} onSelect={onInteractiveSelect} />
135
146
  ) : (
136
147
  <>
137
148
  {isTemplate && (
@@ -35,8 +35,22 @@ export interface MessageComposerClassNames {
35
35
  field: string
36
36
  }
37
37
 
38
- // Paridade com financiamento-imobiliario-bot/apps/web/src/pages/ConversationsPage.tsx:1490
39
- const DEFAULT_ACCEPTED_FILE_TYPES = 'image/*,video/*,audio/*,.pdf,.doc,.docx,.xls,.xlsx,.zip'
38
+ /**
39
+ * Exatamente o que a Meta aceita em mensagem de mídia — imagem, sticker, áudio, vídeo e a lista
40
+ * fechada de documentos. Oferecer no seletor um formato que o WhatsApp recusa (`.zip`, `.rtf`)
41
+ * empurra a falha para depois do envio, quando já não dá para explicar ao operador o que houve.
42
+ * Produto com regra própria passa `acceptedFileTypes`.
43
+ */
44
+ export const DEFAULT_ACCEPTED_FILE_TYPES = [
45
+ 'image/jpeg,image/png,image/webp',
46
+ 'audio/aac,audio/mp4,audio/mpeg,audio/amr,audio/ogg',
47
+ 'video/mp4,video/3gpp',
48
+ 'application/pdf,text/plain,text/csv',
49
+ 'application/msword,application/vnd.ms-excel,application/vnd.ms-powerpoint',
50
+ 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
51
+ 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
52
+ 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
53
+ ].join(',')
40
54
 
41
55
  interface FilePreview {
42
56
  file: File
@@ -0,0 +1,35 @@
1
+ import { describe, expect, it } from 'bun:test'
2
+ import { EMOJI_CATEGORIES, searchEmojis } from './emojiCatalog'
3
+
4
+ describe('searchEmojis', () => {
5
+ it('devolve o catálogo inteiro quando não há termo', () => {
6
+ const total = EMOJI_CATEGORIES.reduce((sum, category) => sum + category.entries.length, 0)
7
+ expect(searchEmojis('').length).toBe(total)
8
+ expect(searchEmojis(' ').length).toBe(total)
9
+ })
10
+
11
+ it('acha pela palavra sem acento e com acento', () => {
12
+ const semAcento = searchEmojis('coracao').map((entry) => entry.emoji)
13
+ const comAcento = searchEmojis('coração').map((entry) => entry.emoji)
14
+
15
+ expect(semAcento).toContain('❤️')
16
+ expect(comAcento).toContain('❤️')
17
+ })
18
+
19
+ it('ignora caixa e casa por prefixo, não pelo meio da palavra', () => {
20
+ expect(searchEmojis('CASA').map((entry) => entry.emoji)).toContain('🏠')
21
+ // "sa" está dentro de "casa", mas não é prefixo de nenhuma palavra-chave dela.
22
+ expect(searchEmojis('sa').map((entry) => entry.emoji)).not.toContain('🏠')
23
+ })
24
+
25
+ it('atravessa categorias, não só a aberta', () => {
26
+ const resultados = searchEmojis('pagamento').map((entry) => entry.emoji)
27
+
28
+ expect(resultados).toContain('💳')
29
+ expect(resultados.length).toBeGreaterThan(1)
30
+ })
31
+
32
+ it('devolve vazio quando nada casa, em vez de cair no catálogo inteiro', () => {
33
+ expect(searchEmojis('xyzabc')).toEqual([])
34
+ })
35
+ })
@@ -0,0 +1,189 @@
1
+ /**
2
+ * Catálogo de emojis com palavras-chave em português, usado pela busca do seletor.
3
+ *
4
+ * Fica em módulo próprio (e não dentro do `EmojiPicker`) porque a busca precisa varrer TODAS as
5
+ * categorias, não só a aberta — o índice é montado uma vez, no carregamento, e não a cada tecla.
6
+ *
7
+ * As palavras-chave são de busca, não legendas: incluem sinônimos e formas sem acento, porque quem
8
+ * digita rápido escreve "coracao" e "polegar" com a mesma frequência que a forma correta.
9
+ */
10
+
11
+ export type EmojiEntry = {
12
+ readonly emoji: string
13
+ readonly keywords: readonly string[]
14
+ }
15
+
16
+ export type EmojiCategory = {
17
+ readonly name: string
18
+ readonly entries: readonly EmojiEntry[]
19
+ }
20
+
21
+ export const EMOJI_CATEGORIES: readonly EmojiCategory[] = [
22
+ {
23
+ name: 'Smileys',
24
+ entries: [
25
+ { emoji: '😀', keywords: ['sorriso', 'feliz', 'alegre'] },
26
+ { emoji: '😃', keywords: ['sorriso', 'feliz', 'animado'] },
27
+ { emoji: '😄', keywords: ['sorriso', 'feliz', 'risada'] },
28
+ { emoji: '😁', keywords: ['sorriso', 'dentes', 'feliz'] },
29
+ { emoji: '😅', keywords: ['alivio', 'alívio', 'suor', 'nervoso'] },
30
+ { emoji: '😂', keywords: ['risada', 'chorando', 'engracado', 'engraçado'] },
31
+ { emoji: '🤣', keywords: ['risada', 'rolando', 'engracado', 'engraçado'] },
32
+ { emoji: '😊', keywords: ['sorriso', 'timido', 'tímido', 'feliz'] },
33
+ { emoji: '😇', keywords: ['anjo', 'inocente', 'santo'] },
34
+ { emoji: '🙂', keywords: ['sorriso', 'leve', 'ok'] },
35
+ { emoji: '😉', keywords: ['piscada', 'piscar', 'flerte'] },
36
+ { emoji: '😌', keywords: ['aliviado', 'calmo', 'tranquilo'] },
37
+ { emoji: '😍', keywords: ['amor', 'apaixonado', 'coracao', 'coração', 'olhos'] },
38
+ { emoji: '🥰', keywords: ['amor', 'apaixonado', 'carinho'] },
39
+ { emoji: '😘', keywords: ['beijo', 'amor', 'carinho'] },
40
+ { emoji: '😋', keywords: ['gostoso', 'delicia', 'delícia', 'lingua', 'língua'] },
41
+ { emoji: '😜', keywords: ['lingua', 'língua', 'brincadeira', 'piscada'] },
42
+ { emoji: '🤪', keywords: ['maluco', 'doido', 'brincadeira'] },
43
+ { emoji: '🤔', keywords: ['pensando', 'duvida', 'dúvida', 'hmm'] },
44
+ { emoji: '🤗', keywords: ['abraco', 'abraço', 'carinho'] },
45
+ { emoji: '😐', keywords: ['neutro', 'serio', 'sério', 'indiferente'] },
46
+ { emoji: '😴', keywords: ['dormindo', 'sono', 'cansado'] },
47
+ { emoji: '😭', keywords: ['chorando', 'triste', 'lagrima', 'lágrima'] },
48
+ { emoji: '😢', keywords: ['triste', 'chorando', 'lagrima', 'lágrima'] },
49
+ { emoji: '😡', keywords: ['raiva', 'bravo', 'irritado'] },
50
+ { emoji: '😱', keywords: ['susto', 'medo', 'assustado'] },
51
+ { emoji: '🤯', keywords: ['explodindo', 'chocado', 'surpresa'] },
52
+ { emoji: '😎', keywords: ['oculos', 'óculos', 'legal', 'estiloso'] },
53
+ { emoji: '🥳', keywords: ['festa', 'comemorar', 'aniversario', 'aniversário'] },
54
+ { emoji: '😷', keywords: ['mascara', 'máscara', 'doente', 'saude', 'saúde'] },
55
+ ],
56
+ },
57
+ {
58
+ name: 'Gestos',
59
+ entries: [
60
+ { emoji: '👍', keywords: ['joia', 'jóia', 'polegar', 'ok', 'positivo', 'curtir'] },
61
+ { emoji: '👎', keywords: ['polegar', 'negativo', 'ruim', 'nao', 'não'] },
62
+ { emoji: '👌', keywords: ['ok', 'certo', 'perfeito'] },
63
+ { emoji: '✌️', keywords: ['paz', 'vitoria', 'vitória', 'dois'] },
64
+ { emoji: '🤞', keywords: ['sorte', 'dedos', 'cruzados', 'torcendo'] },
65
+ { emoji: '🤙', keywords: ['chama', 'ligar', 'shaka'] },
66
+ { emoji: '👋', keywords: ['tchau', 'ola', 'olá', 'aceno', 'oi'] },
67
+ { emoji: '✋', keywords: ['mao', 'mão', 'parar', 'pare'] },
68
+ { emoji: '👏', keywords: ['palmas', 'aplauso', 'parabens', 'parabéns'] },
69
+ { emoji: '🙌', keywords: ['comemorar', 'maos', 'mãos', 'sucesso'] },
70
+ { emoji: '🤝', keywords: ['acordo', 'aperto', 'mao', 'mão', 'negocio', 'negócio', 'parceria'] },
71
+ { emoji: '🙏', keywords: ['obrigado', 'reza', 'oracao', 'oração', 'por favor'] },
72
+ { emoji: '✍️', keywords: ['escrever', 'assinar', 'assinatura'] },
73
+ { emoji: '💪', keywords: ['forca', 'força', 'musculo', 'músculo', 'braco', 'braço'] },
74
+ { emoji: '👇', keywords: ['abaixo', 'baixo', 'apontar', 'seta'] },
75
+ { emoji: '👉', keywords: ['direita', 'apontar', 'seta'] },
76
+ { emoji: '☝️', keywords: ['acima', 'cima', 'apontar', 'atencao', 'atenção'] },
77
+ ],
78
+ },
79
+ {
80
+ name: 'Corações',
81
+ entries: [
82
+ { emoji: '❤️', keywords: ['coracao', 'coração', 'amor', 'vermelho'] },
83
+ { emoji: '🧡', keywords: ['coracao', 'coração', 'laranja'] },
84
+ { emoji: '💛', keywords: ['coracao', 'coração', 'amarelo'] },
85
+ { emoji: '💚', keywords: ['coracao', 'coração', 'verde'] },
86
+ { emoji: '💙', keywords: ['coracao', 'coração', 'azul'] },
87
+ { emoji: '💜', keywords: ['coracao', 'coração', 'roxo'] },
88
+ { emoji: '🖤', keywords: ['coracao', 'coração', 'preto'] },
89
+ { emoji: '🤍', keywords: ['coracao', 'coração', 'branco'] },
90
+ { emoji: '💔', keywords: ['coracao', 'coração', 'partido', 'triste'] },
91
+ { emoji: '💕', keywords: ['coracao', 'coração', 'amor', 'casal'] },
92
+ { emoji: '💖', keywords: ['coracao', 'coração', 'brilho', 'amor'] },
93
+ { emoji: '💝', keywords: ['coracao', 'coração', 'presente', 'laco', 'laço'] },
94
+ ],
95
+ },
96
+ {
97
+ name: 'Negócios',
98
+ entries: [
99
+ { emoji: '🏠', keywords: ['casa', 'imovel', 'imóvel', 'residencia', 'residência', 'moradia'] },
100
+ { emoji: '🏡', keywords: ['casa', 'imovel', 'imóvel', 'jardim', 'moradia'] },
101
+ { emoji: '🏢', keywords: ['predio', 'prédio', 'empresa', 'escritorio', 'escritório'] },
102
+ { emoji: '🏦', keywords: ['banco', 'financiamento', 'agencia', 'agência'] },
103
+ { emoji: '🔑', keywords: ['chave', 'casa', 'entrega', 'imovel', 'imóvel'] },
104
+ { emoji: '📄', keywords: ['documento', 'papel', 'contrato', 'arquivo'] },
105
+ { emoji: '📋', keywords: ['prancheta', 'lista', 'documento', 'checklist'] },
106
+ { emoji: '📝', keywords: ['anotar', 'escrever', 'nota', 'formulario', 'formulário'] },
107
+ { emoji: '✅', keywords: ['ok', 'certo', 'aprovado', 'concluido', 'concluído', 'check'] },
108
+ { emoji: '❌', keywords: ['errado', 'negado', 'recusado', 'cancelar'] },
109
+ { emoji: '⚠️', keywords: ['atencao', 'atenção', 'alerta', 'cuidado'] },
110
+ { emoji: '💰', keywords: ['dinheiro', 'valor', 'saco', 'grana', 'pagamento'] },
111
+ { emoji: '💵', keywords: ['dinheiro', 'nota', 'valor', 'pagamento'] },
112
+ { emoji: '💳', keywords: ['cartao', 'cartão', 'credito', 'crédito', 'pagamento'] },
113
+ { emoji: '🧾', keywords: ['recibo', 'nota', 'fiscal', 'comprovante'] },
114
+ { emoji: '📊', keywords: ['grafico', 'gráfico', 'relatorio', 'relatório', 'dados'] },
115
+ { emoji: '📈', keywords: ['grafico', 'gráfico', 'subindo', 'crescimento', 'alta'] },
116
+ { emoji: '📉', keywords: ['grafico', 'gráfico', 'caindo', 'queda', 'baixa'] },
117
+ { emoji: '🗓️', keywords: ['calendario', 'calendário', 'data', 'agenda', 'prazo'] },
118
+ { emoji: '⏰', keywords: ['relogio', 'relógio', 'hora', 'prazo', 'alarme'] },
119
+ { emoji: '📞', keywords: ['telefone', 'ligar', 'contato', 'chamada'] },
120
+ { emoji: '📱', keywords: ['celular', 'telefone', 'whatsapp', 'contato'] },
121
+ { emoji: '📧', keywords: ['email', 'e-mail', 'mensagem', 'contato'] },
122
+ { emoji: '📎', keywords: ['anexo', 'clipe', 'arquivo'] },
123
+ { emoji: '🔍', keywords: ['buscar', 'procurar', 'lupa', 'pesquisa', 'consulta'] },
124
+ { emoji: '🤖', keywords: ['robo', 'robô', 'bot', 'assistente', 'automatico', 'automático'] },
125
+ { emoji: '💬', keywords: ['mensagem', 'conversa', 'balao', 'balão', 'chat'] },
126
+ ],
127
+ },
128
+ {
129
+ name: 'Objetos',
130
+ entries: [
131
+ { emoji: '🎁', keywords: ['presente', 'brinde', 'surpresa'] },
132
+ { emoji: '🎉', keywords: ['festa', 'comemorar', 'parabens', 'parabéns'] },
133
+ { emoji: '🎊', keywords: ['festa', 'confete', 'comemorar'] },
134
+ { emoji: '🎂', keywords: ['bolo', 'aniversario', 'aniversário', 'festa'] },
135
+ { emoji: '💡', keywords: ['ideia', 'ideía', 'lampada', 'lâmpada', 'dica'] },
136
+ { emoji: '🔔', keywords: ['sino', 'aviso', 'notificacao', 'notificação', 'lembrete'] },
137
+ { emoji: '⭐', keywords: ['estrela', 'favorito', 'avaliacao', 'avaliação'] },
138
+ { emoji: '🔥', keywords: ['fogo', 'quente', 'destaque', 'top'] },
139
+ { emoji: '🚀', keywords: ['foguete', 'rapido', 'rápido', 'lancamento', 'lançamento'] },
140
+ { emoji: '💻', keywords: ['computador', 'notebook', 'trabalho'] },
141
+ { emoji: '📷', keywords: ['foto', 'camera', 'câmera', 'imagem'] },
142
+ { emoji: '🚗', keywords: ['carro', 'veiculo', 'veículo', 'automovel', 'automóvel'] },
143
+ { emoji: '✈️', keywords: ['aviao', 'avião', 'viagem', 'voo'] },
144
+ ],
145
+ },
146
+ {
147
+ name: 'Comida',
148
+ entries: [
149
+ { emoji: '🍔', keywords: ['hamburguer', 'hambúrguer', 'lanche', 'comida'] },
150
+ { emoji: '🍕', keywords: ['pizza', 'comida', 'lanche'] },
151
+ { emoji: '🍟', keywords: ['batata', 'frita', 'lanche'] },
152
+ { emoji: '🍿', keywords: ['pipoca', 'cinema', 'filme'] },
153
+ { emoji: '🍞', keywords: ['pao', 'pão', 'padaria'] },
154
+ { emoji: '🧀', keywords: ['queijo', 'comida'] },
155
+ { emoji: '🥗', keywords: ['salada', 'saudavel', 'saudável', 'comida'] },
156
+ { emoji: '☕', keywords: ['cafe', 'café', 'bebida', 'quente'] },
157
+ { emoji: '🍺', keywords: ['cerveja', 'bebida', 'chopp'] },
158
+ { emoji: '🍷', keywords: ['vinho', 'bebida', 'taca', 'taça'] },
159
+ { emoji: '🥂', keywords: ['brinde', 'comemorar', 'champanhe'] },
160
+ { emoji: '🥤', keywords: ['refrigerante', 'bebida', 'copo'] },
161
+ ],
162
+ },
163
+ ] as const
164
+
165
+ /**
166
+ * Índice achatado: a busca ignora categoria, porque quem digita "casa" quer o resultado venha de
167
+ * onde vier. A ordem preserva a das categorias, então o resultado sai agrupado por afinidade sem
168
+ * precisar ordenar.
169
+ */
170
+ const ALL_ENTRIES: readonly EmojiEntry[] = EMOJI_CATEGORIES.flatMap((category) => category.entries)
171
+
172
+ function normalize(value: string): string {
173
+ return value
174
+ .toLowerCase()
175
+ .normalize('NFD')
176
+ .replace(/\p{Diacritic}/gu, '')
177
+ .trim()
178
+ }
179
+
180
+ /**
181
+ * Busca por prefixo de palavra-chave, não por substring: "ca" traz "casa" e "cartão", mas "sa" não
182
+ * traz "casa" — casar no meio da palavra devolvia resultado que ninguém consegue explicar.
183
+ */
184
+ export function searchEmojis(query: string): readonly EmojiEntry[] {
185
+ const term = normalize(query)
186
+ if (!term) return ALL_ENTRIES
187
+
188
+ return ALL_ENTRIES.filter((entry) => entry.keywords.some((keyword) => normalize(keyword).startsWith(term)))
189
+ }
package/src/index.ts CHANGED
@@ -1,9 +1,11 @@
1
1
  export { MessageBubble } from './MessageBubble'
2
+ export { InteractiveMessage, DEFAULT_INTERACTIVE_MESSAGE_LABELS } from './InteractiveMessage'
2
3
  export { ConversationWallpaper } from './Wallpaper'
3
4
  export { ConversationLocalesProvider, useConversationLocales } from './ConversationLocalesProvider'
4
5
  export { AudioPlayer } from './AudioPlayer'
5
- export { EmojiPicker } from './EmojiPicker'
6
- export { MessageComposer, DEFAULT_MESSAGE_COMPOSER_LABELS } from './MessageComposer'
6
+ export { EmojiPicker, DEFAULT_EMOJI_PICKER_LABELS } from './EmojiPicker'
7
+ export { EMOJI_CATEGORIES, searchEmojis } from './emojiCatalog'
8
+ export { MessageComposer, DEFAULT_MESSAGE_COMPOSER_LABELS, DEFAULT_ACCEPTED_FILE_TYPES } from './MessageComposer'
7
9
  export { WhatsAppMessageEditor, DEFAULT_WHATSAPP_MESSAGE_EDITOR_LABELS } from './WhatsAppMessageEditor'
8
10
  export { SimpleEmojiPicker } from './SimpleEmojiPicker'
9
11
  export { DateDivider } from './DateDivider'
@@ -114,6 +116,7 @@ export { formatPhone, phoneInitials } from './lib/phone'
114
116
  export { formatTimestamp, formatFileSize, formatDateTime, isSameDay } from './lib/format'
115
117
 
116
118
  export type { MessagePayload, ConversationsUIConfig, ConversationsTheme, ConversationsFeatures } from './types'
119
+ export type { InteractivePayload, InteractiveSection, InteractiveOption, InteractiveSelection } from './types'
117
120
  export type {
118
121
  ConversationsApi,
119
122
  SSEProvider,
@@ -134,7 +137,9 @@ export type { MessageBubbleProps } from './MessageBubble'
134
137
  export type { ConversationWallpaperProps } from './Wallpaper'
135
138
  export type { ConversationLocales, ConversationLocalesProviderProps } from './ConversationLocalesProvider'
136
139
  export type { AudioPlayerProps } from './AudioPlayer'
137
- export type { EmojiPickerProps } from './EmojiPicker'
140
+ export type { EmojiPickerProps, EmojiPickerLabels } from './EmojiPicker'
141
+ export type { EmojiEntry, EmojiCategory } from './emojiCatalog'
142
+ export type { InteractiveMessageProps, InteractiveMessageLabels } from './InteractiveMessage'
138
143
  export type { MessageComposerProps, MessageComposerClassNames, MessageComposerLabels } from './MessageComposer'
139
144
  export type { WhatsAppMessageEditorProps, WhatsAppMessageEditorLabels } from './WhatsAppMessageEditor'
140
145
  export type { SimpleEmojiPickerProps } from './SimpleEmojiPicker'
@@ -175,3 +180,4 @@ export type { UseConversationDocumentsParams, UseConversationDocumentsResult } f
175
180
  export type { ConversationRealtimeHandler } from './hooks/useConversationRealtime'
176
181
  export type { AsyncResourceState } from './hooks/useAsyncResource'
177
182
  export { createMediaUrlResolver } from './lib/createMediaUrlResolver'
183
+ export type { ConversationHeaderUtility } from './ConversationHeader'
@@ -0,0 +1,117 @@
1
+ /**
2
+ * Gravação de áudio no simulador, pelo microfone do próprio navegador.
3
+ *
4
+ * Existe porque áudio é o formato que mais chega de cliente real e o que mais quebra fluxo: sem
5
+ * poder gravar aqui, testar o caminho de transcrição exigia mandar mensagem do celular de alguém.
6
+ *
7
+ * O arquivo gravado sai daqui como `File` e segue exatamente o mesmo caminho de um anexo — quem
8
+ * hospeda e devolve o `mediaId` é o host, via `uploadMedia`.
9
+ */
10
+
11
+ import { useCallback, useRef, useState } from 'react'
12
+
13
+ export interface AudioRecorderButtonLabels {
14
+ start: string
15
+ stop: string
16
+ unsupported: string
17
+ denied: string
18
+ }
19
+
20
+ export const DEFAULT_AUDIO_RECORDER_BUTTON_LABELS: AudioRecorderButtonLabels = {
21
+ start: 'Gravar áudio',
22
+ stop: 'Parar gravação',
23
+ unsupported: 'Este navegador não grava áudio.',
24
+ denied: 'Sem permissão para usar o microfone.',
25
+ }
26
+
27
+ export interface AudioRecorderButtonProps {
28
+ onRecorded: (file: File) => void | Promise<void>
29
+ onFailure?: (message: string) => void
30
+ labels?: Partial<AudioRecorderButtonLabels>
31
+ disabled?: boolean
32
+ }
33
+
34
+ /**
35
+ * Ordem de preferência de formato: os dois primeiros o WhatsApp aceita como áudio; `webm` é só
36
+ * saída de emergência para navegador que não grava mais nada — gravar em webm e descobrir na hora
37
+ * do envio que o formato é inválido é pior do que gravar já no formato certo.
38
+ */
39
+ const RECORDING_FORMATS = [
40
+ { mimeType: 'audio/ogg;codecs=opus', uploadMimeType: 'audio/ogg', extension: 'ogg' },
41
+ { mimeType: 'audio/mp4', uploadMimeType: 'audio/mp4', extension: 'm4a' },
42
+ { mimeType: 'audio/webm', uploadMimeType: 'audio/webm', extension: 'webm' },
43
+ ] as const
44
+
45
+ export type RecordingFormat = (typeof RECORDING_FORMATS)[number]
46
+
47
+ /** Primeiro formato que o navegador sabe gravar, ou `undefined` se não souber gravar nenhum. */
48
+ export function resolveRecordingFormat(): RecordingFormat | undefined {
49
+ if (typeof MediaRecorder === 'undefined') return undefined
50
+ // `isTypeSupported` não existe em toda implementação; onde falta, o primeiro da lista é o palpite.
51
+ if (typeof MediaRecorder.isTypeSupported !== 'function') return RECORDING_FORMATS[0]
52
+ return RECORDING_FORMATS.find((format) => MediaRecorder.isTypeSupported(format.mimeType))
53
+ }
54
+
55
+ export function AudioRecorderButton({ onRecorded, onFailure, labels, disabled }: AudioRecorderButtonProps) {
56
+ const startLabel = labels?.start ?? DEFAULT_AUDIO_RECORDER_BUTTON_LABELS.start
57
+ const stopLabel = labels?.stop ?? DEFAULT_AUDIO_RECORDER_BUTTON_LABELS.stop
58
+ const [isRecording, setIsRecording] = useState(false)
59
+ const recorderRef = useRef<MediaRecorder | null>(null)
60
+
61
+ const stop = useCallback(() => {
62
+ recorderRef.current?.stop()
63
+ }, [])
64
+
65
+ const start = useCallback(async () => {
66
+ const format = resolveRecordingFormat()
67
+ if (!format || !navigator.mediaDevices?.getUserMedia) {
68
+ onFailure?.(labels?.unsupported ?? DEFAULT_AUDIO_RECORDER_BUTTON_LABELS.unsupported)
69
+ return
70
+ }
71
+
72
+ try {
73
+ const stream = await navigator.mediaDevices.getUserMedia({ audio: true })
74
+ const recorder = new MediaRecorder(stream, { mimeType: format.mimeType })
75
+ const chunks: Blob[] = []
76
+
77
+ recorder.addEventListener('dataavailable', (event) => {
78
+ if (event.data.size > 0) chunks.push(event.data)
79
+ })
80
+ recorder.addEventListener('stop', () => {
81
+ // Solta o microfone assim que para: sem isto o indicador de gravação do navegador fica
82
+ // aceso depois do envio, e o operador acha que o simulador continua ouvindo.
83
+ stream.getTracks().forEach((track) => track.stop())
84
+ setIsRecording(false)
85
+ recorderRef.current = null
86
+ // O `File` sai com o MIME sem os parâmetros de codec: `audio/ogg;codecs=opus` serve ao
87
+ // gravador, mas quem valida upload compara com `audio/ogg` puro.
88
+ const blob = new Blob(chunks, { type: format.uploadMimeType })
89
+ void onRecorded(
90
+ new File([blob], `audio-${Date.now()}.${format.extension}`, { type: format.uploadMimeType }),
91
+ )
92
+ })
93
+
94
+ recorderRef.current = recorder
95
+ recorder.start()
96
+ setIsRecording(true)
97
+ } catch {
98
+ onFailure?.(labels?.denied ?? DEFAULT_AUDIO_RECORDER_BUTTON_LABELS.denied)
99
+ }
100
+ }, [labels?.denied, labels?.unsupported, onFailure, onRecorded])
101
+
102
+ return (
103
+ <button
104
+ type="button"
105
+ disabled={disabled}
106
+ onClick={() => (isRecording ? stop() : void start())}
107
+ title={isRecording ? stopLabel : startLabel}
108
+ aria-label={isRecording ? stopLabel : startLabel}
109
+ aria-pressed={isRecording}
110
+ className={`flex h-9 w-9 items-center justify-center rounded-full transition-colors ${
111
+ isRecording ? 'bg-red-100 text-red-600 dark:bg-red-900/40' : 'text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-700'
112
+ }`}
113
+ >
114
+ {isRecording ? '■' : '🎤'}
115
+ </button>
116
+ )
117
+ }