@adatechnology/conversations-ui 0.1.0-rc.5 → 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.
- package/dist/chunk-TGTBMMFC.js +1707 -0
- package/dist/index.d.ts +209 -22
- package/dist/index.js +243 -590
- package/dist/preview/index.d.ts +153 -9
- package/dist/preview/index.js +520 -38
- package/dist/{types-C2Yexi8A.d.ts → types-B5C1DLu1.d.ts} +70 -2
- package/package.json +2 -2
- package/src/Avatar.tsx +13 -2
- package/src/ConversationHeader.tsx +18 -0
- package/src/ConversationListItem.tsx +18 -2
- package/src/DocumentsLibrary.tsx +322 -0
- package/src/EmojiPicker.tsx +69 -55
- package/src/FileIcon.test.ts +46 -1
- package/src/FileIcon.tsx +76 -10
- package/src/InteractiveMessage.tsx +126 -0
- package/src/Lightbox.tsx +18 -3
- package/src/MessageBubble.tsx +31 -4
- package/src/MessageComposer.tsx +36 -5
- package/src/WhatsAppMessageEditor.tsx +28 -4
- package/src/emojiCatalog.test.ts +35 -0
- package/src/emojiCatalog.ts +189 -0
- package/src/index.ts +25 -12
- package/src/lib/createMediaUrlResolver.ts +33 -0
- package/src/preview/AudioRecorderButton.tsx +117 -0
- package/src/preview/ConversationPreview.tsx +184 -15
- package/src/preview/MediaTypesPreview.tsx +87 -0
- package/src/preview/audioRecorderFormat.test.ts +67 -0
- package/src/preview/conversationPreviewFailures.test.ts +64 -0
- package/src/preview/createMockConversationsApi.ts +62 -8
- package/src/preview/createPreviewWebhookClient.ts +28 -1
- package/src/preview/index.ts +16 -2
- package/src/preview/mediaTypeOf.test.ts +15 -0
- package/src/preview/mockDocumentsSearch.test.ts +57 -0
- package/src/preview/previewFileSamples.test.ts +151 -0
- package/src/preview/previewFileSamples.ts +74 -0
- package/src/preview/previewFixtures.ts +140 -8
- package/src/preview/previewMediaSource.test.ts +62 -0
- package/src/preview/previewMediaSource.ts +91 -0
- package/src/providers/types.ts +17 -0
- package/src/settings/WhatsAppCreateTemplateForm.tsx +3 -1
- package/src/types.ts +38 -1
- package/dist/chunk-YWITIIHD.js +0 -728
|
@@ -1,7 +1,29 @@
|
|
|
1
1
|
import { useRef, type ReactNode } from 'react'
|
|
2
2
|
import { Bold, Italic, Strikethrough } from 'lucide-react'
|
|
3
3
|
|
|
4
|
+
export interface WhatsAppMessageEditorLabels {
|
|
5
|
+
bold: string
|
|
6
|
+
/** Tooltip da negrito — traz a sintaxe do WhatsApp junto, por isso é separado do `aria-label`. */
|
|
7
|
+
boldHint: string
|
|
8
|
+
italic: string
|
|
9
|
+
italicHint: string
|
|
10
|
+
strikethrough: string
|
|
11
|
+
strikethroughHint: string
|
|
12
|
+
insertPlaceholder: (token: string) => string
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export const DEFAULT_WHATSAPP_MESSAGE_EDITOR_LABELS: WhatsAppMessageEditorLabels = {
|
|
16
|
+
bold: 'Negrito',
|
|
17
|
+
boldHint: 'Negrito (*texto*)',
|
|
18
|
+
italic: 'Itálico',
|
|
19
|
+
italicHint: 'Itálico (_texto_)',
|
|
20
|
+
strikethrough: 'Tachado',
|
|
21
|
+
strikethroughHint: 'Tachado (~texto~)',
|
|
22
|
+
insertPlaceholder: (token: string) => `Inserir ${token}`,
|
|
23
|
+
}
|
|
24
|
+
|
|
4
25
|
export interface WhatsAppMessageEditorProps {
|
|
26
|
+
labels?: Partial<WhatsAppMessageEditorLabels>
|
|
5
27
|
value: string
|
|
6
28
|
onChange: (value: string) => void
|
|
7
29
|
placeholder?: string
|
|
@@ -56,7 +78,9 @@ export function WhatsAppMessageEditor({
|
|
|
56
78
|
rows = 4,
|
|
57
79
|
previewLabel = 'Prévia (como aparece no WhatsApp)',
|
|
58
80
|
emptyPreviewText = 'Sua mensagem aparecerá aqui…',
|
|
81
|
+
labels,
|
|
59
82
|
}: WhatsAppMessageEditorProps) {
|
|
83
|
+
const editorLabels = { ...DEFAULT_WHATSAPP_MESSAGE_EDITOR_LABELS, ...labels }
|
|
60
84
|
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
|
61
85
|
|
|
62
86
|
function wrapSelection(marker: string): void {
|
|
@@ -93,13 +117,13 @@ export function WhatsAppMessageEditor({
|
|
|
93
117
|
return (
|
|
94
118
|
<div>
|
|
95
119
|
<div className="flex flex-wrap items-center gap-1.5 mb-2">
|
|
96
|
-
<button type="button" onClick={() => wrapSelection('*')} className={toolbarButtonClass} title=
|
|
120
|
+
<button type="button" onClick={() => wrapSelection('*')} className={toolbarButtonClass} title={editorLabels.boldHint} aria-label={editorLabels.bold}>
|
|
97
121
|
<Bold size={15} />
|
|
98
122
|
</button>
|
|
99
|
-
<button type="button" onClick={() => wrapSelection('_')} className={toolbarButtonClass} title=
|
|
123
|
+
<button type="button" onClick={() => wrapSelection('_')} className={toolbarButtonClass} title={editorLabels.italicHint} aria-label={editorLabels.italic}>
|
|
100
124
|
<Italic size={15} />
|
|
101
125
|
</button>
|
|
102
|
-
<button type="button" onClick={() => wrapSelection('~')} className={toolbarButtonClass} title=
|
|
126
|
+
<button type="button" onClick={() => wrapSelection('~')} className={toolbarButtonClass} title={editorLabels.strikethroughHint} aria-label={editorLabels.strikethrough}>
|
|
103
127
|
<Strikethrough size={15} />
|
|
104
128
|
</button>
|
|
105
129
|
{placeholders.length > 0 && (
|
|
@@ -111,7 +135,7 @@ export function WhatsAppMessageEditor({
|
|
|
111
135
|
type="button"
|
|
112
136
|
onClick={() => insertAtCursor(token)}
|
|
113
137
|
className="inline-flex items-center h-8 px-2 rounded-lg border border-gray-200 dark:border-gray-600 text-xs text-blue-600 dark:text-blue-300 hover:bg-blue-50 dark:hover:bg-blue-900/40 transition-colors"
|
|
114
|
-
title={
|
|
138
|
+
title={editorLabels.insertPlaceholder(token)}
|
|
115
139
|
>
|
|
116
140
|
{token}
|
|
117
141
|
</button>
|
|
@@ -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,18 +1,20 @@
|
|
|
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 {
|
|
7
|
-
export {
|
|
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'
|
|
9
|
+
export { WhatsAppMessageEditor, DEFAULT_WHATSAPP_MESSAGE_EDITOR_LABELS } from './WhatsAppMessageEditor'
|
|
8
10
|
export { SimpleEmojiPicker } from './SimpleEmojiPicker'
|
|
9
11
|
export { DateDivider } from './DateDivider'
|
|
10
|
-
export { Avatar } from './Avatar'
|
|
11
|
-
export { ConversationListItem } from './ConversationListItem'
|
|
12
|
+
export { Avatar, DEFAULT_AVATAR_LABELS } from './Avatar'
|
|
13
|
+
export { ConversationListItem, DEFAULT_CONVERSATION_LIST_ITEM_LABELS } from './ConversationListItem'
|
|
12
14
|
export { ToastProvider, useToast, toast } from './Toast'
|
|
13
15
|
|
|
14
16
|
export { StatusTicks } from './StatusTicks'
|
|
15
|
-
export { Lightbox } from './Lightbox'
|
|
17
|
+
export { Lightbox, DEFAULT_LIGHTBOX_LABELS } from './Lightbox'
|
|
16
18
|
export { MediaRenderer } from './MediaRenderer'
|
|
17
19
|
export { FileIcon } from './FileIcon'
|
|
18
20
|
export { MessageText } from './MessageText'
|
|
@@ -65,6 +67,10 @@ export type {
|
|
|
65
67
|
} from './ConversationContextPanel'
|
|
66
68
|
export { WindowExpiredNotice, isWindowBlocking, DEFAULT_WINDOW_EXPIRED_LABELS } from './WindowExpiredNotice'
|
|
67
69
|
export type { WindowExpiredNoticeProps, WindowExpiredNoticeLabels } from './WindowExpiredNotice'
|
|
70
|
+
export { DocumentsLibrary, DEFAULT_DOCUMENTS_LIBRARY_LABELS } from './DocumentsLibrary'
|
|
71
|
+
export type { DocumentsLibraryProps, DocumentsLibraryLabels, DocumentsLibraryClassNames } from './DocumentsLibrary'
|
|
72
|
+
export { DOCUMENT_SOURCE_FILTER } from './ConversationDocumentsPanel'
|
|
73
|
+
export type { DocumentSourceFilter } from './ConversationDocumentsPanel'
|
|
68
74
|
export type { ConversationDocumentsPanelClassNames } from './ConversationDocumentsPanel'
|
|
69
75
|
export { ConversationDocumentsPanel, DEFAULT_CONVERSATION_DOCUMENTS_LABELS } from './ConversationDocumentsPanel'
|
|
70
76
|
export type { ConversationDocumentsPanelProps, ConversationDocumentsPanelLabels } from './ConversationDocumentsPanel'
|
|
@@ -110,6 +116,7 @@ export { formatPhone, phoneInitials } from './lib/phone'
|
|
|
110
116
|
export { formatTimestamp, formatFileSize, formatDateTime, isSameDay } from './lib/format'
|
|
111
117
|
|
|
112
118
|
export type { MessagePayload, ConversationsUIConfig, ConversationsTheme, ConversationsFeatures } from './types'
|
|
119
|
+
export type { InteractivePayload, InteractiveSection, InteractiveOption, InteractiveSelection } from './types'
|
|
113
120
|
export type {
|
|
114
121
|
ConversationsApi,
|
|
115
122
|
SSEProvider,
|
|
@@ -118,6 +125,8 @@ export type {
|
|
|
118
125
|
ConversationDocument,
|
|
119
126
|
ConversationPage,
|
|
120
127
|
ConversationDocumentPage,
|
|
128
|
+
CompanyDocument,
|
|
129
|
+
CompanyDocumentPage,
|
|
121
130
|
ConversationTemplate,
|
|
122
131
|
ListConversationsParams,
|
|
123
132
|
ListDocumentsParams,
|
|
@@ -128,15 +137,17 @@ export type { MessageBubbleProps } from './MessageBubble'
|
|
|
128
137
|
export type { ConversationWallpaperProps } from './Wallpaper'
|
|
129
138
|
export type { ConversationLocales, ConversationLocalesProviderProps } from './ConversationLocalesProvider'
|
|
130
139
|
export type { AudioPlayerProps } from './AudioPlayer'
|
|
131
|
-
export type { EmojiPickerProps } from './EmojiPicker'
|
|
132
|
-
export type {
|
|
133
|
-
export type {
|
|
140
|
+
export type { EmojiPickerProps, EmojiPickerLabels } from './EmojiPicker'
|
|
141
|
+
export type { EmojiEntry, EmojiCategory } from './emojiCatalog'
|
|
142
|
+
export type { InteractiveMessageProps, InteractiveMessageLabels } from './InteractiveMessage'
|
|
143
|
+
export type { MessageComposerProps, MessageComposerClassNames, MessageComposerLabels } from './MessageComposer'
|
|
144
|
+
export type { WhatsAppMessageEditorProps, WhatsAppMessageEditorLabels } from './WhatsAppMessageEditor'
|
|
134
145
|
export type { SimpleEmojiPickerProps } from './SimpleEmojiPicker'
|
|
135
146
|
export type { DateDividerProps, DateDividerClassNames } from './DateDivider'
|
|
136
|
-
export type { AvatarProps } from './Avatar'
|
|
137
|
-
export type { ConversationListItemProps } from './ConversationListItem'
|
|
147
|
+
export type { AvatarProps, AvatarLabels } from './Avatar'
|
|
148
|
+
export type { ConversationListItemProps, ConversationListItemLabels } from './ConversationListItem'
|
|
138
149
|
export type { StatusTicksProps } from './StatusTicks'
|
|
139
|
-
export type { LightboxProps } from './Lightbox'
|
|
150
|
+
export type { LightboxProps, LightboxLabels } from './Lightbox'
|
|
140
151
|
export type { MediaRendererProps, ResolveMediaUrl } from './MediaRenderer'
|
|
141
152
|
export type { FileIconProps } from './FileIcon'
|
|
142
153
|
export type { MessageTextProps } from './MessageText'
|
|
@@ -168,3 +179,5 @@ export type { UseConversationContextResult } from './hooks/useConversationContex
|
|
|
168
179
|
export type { UseConversationDocumentsParams, UseConversationDocumentsResult } from './hooks/useConversationDocuments'
|
|
169
180
|
export type { ConversationRealtimeHandler } from './hooks/useConversationRealtime'
|
|
170
181
|
export type { AsyncResourceState } from './hooks/useAsyncResource'
|
|
182
|
+
export { createMediaUrlResolver } from './lib/createMediaUrlResolver'
|
|
183
|
+
export type { ConversationHeaderUtility } from './ConversationHeader'
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolve a mídia de uma mensagem numa URL exibível, usando só o `ConversationsApi`.
|
|
3
|
+
*
|
|
4
|
+
* Mora no pacote, e não em cada host, porque a regra não tem nada de específico de produto: é a
|
|
5
|
+
* tradução de `uploadId`/`mediaId` pelos dois métodos que o próprio contrato já declara. Deixá-la no
|
|
6
|
+
* host significava que todo projeto que adotasse o SDK reescreveria as mesmas oito linhas — e, na
|
|
7
|
+
* prática, ninguém escrevia: o `MediaRenderer` só busca mídia pela porta `onResolveMediaUrl`, então
|
|
8
|
+
* onde nada era injetado foto, vídeo e áudio ficavam no placeholder para sempre.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { MessagePayload } from '../types'
|
|
12
|
+
import type { ConversationsApi } from '../providers/types'
|
|
13
|
+
import type { ResolveMediaUrl } from '../MediaRenderer'
|
|
14
|
+
|
|
15
|
+
export function createMediaUrlResolver(
|
|
16
|
+
api: Pick<ConversationsApi, 'getDocumentUrl' | 'getMediaProxyUrl'>,
|
|
17
|
+
): ResolveMediaUrl {
|
|
18
|
+
return async (message: MessagePayload): Promise<string | null> => {
|
|
19
|
+
// Mídia já copiada para o storage do host: sai por URL assinada e o binário não passa pela API.
|
|
20
|
+
// `inline` porque aqui o arquivo é para VER na tela — `attachment` faria o navegador baixar.
|
|
21
|
+
if (message.uploadId) return api.getDocumentUrl(message.uploadId, 'inline')
|
|
22
|
+
|
|
23
|
+
// Antes da ingestão só existe o id na Meta, cuja URL expira; o backend busca e devolve base64.
|
|
24
|
+
// Data URL serve de `src` para `<img>`/`<video>`/`<audio>`: o bloqueio do Chrome a `data:` vale
|
|
25
|
+
// para navegação de topo, não para carregar mídia dentro da página.
|
|
26
|
+
if (message.mediaId) {
|
|
27
|
+
const { mimeType, data } = await api.getMediaProxyUrl(message.mediaId)
|
|
28
|
+
return `data:${mimeType};base64,${data}`
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return null
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -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
|
+
}
|