@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
package/src/EmojiPicker.tsx
CHANGED
|
@@ -1,39 +1,27 @@
|
|
|
1
|
-
import { useState, useCallback } from 'react'
|
|
1
|
+
import { useState, useCallback, useMemo } from 'react'
|
|
2
|
+
import { EMOJI_CATEGORIES, searchEmojis, type EmojiEntry } from './emojiCatalog'
|
|
3
|
+
|
|
4
|
+
export interface EmojiPickerLabels {
|
|
5
|
+
search: string
|
|
6
|
+
noResults: string
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export const DEFAULT_EMOJI_PICKER_LABELS: EmojiPickerLabels = {
|
|
10
|
+
search: 'Buscar emoji',
|
|
11
|
+
noResults: 'Nenhum emoji encontrado',
|
|
12
|
+
}
|
|
2
13
|
|
|
3
14
|
export interface EmojiPickerProps {
|
|
4
15
|
onSelect: (emoji: string) => void
|
|
16
|
+
labels?: Partial<EmojiPickerLabels>
|
|
5
17
|
className?: string
|
|
6
18
|
}
|
|
7
19
|
|
|
8
|
-
const
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
emojis: ['π', 'π', 'π', 'π', 'π
', 'π', 'π€£', 'π', 'π', 'π', 'π', 'π', 'π', 'π₯°', 'π', 'π', 'π', 'π', 'π', 'π€ͺ'],
|
|
12
|
-
},
|
|
13
|
-
{
|
|
14
|
-
name: 'Gestures',
|
|
15
|
-
emojis: ['π', 'π', 'π', 'βοΈ', 'π€', 'π€', 'π€', 'π€', 'π', 'π€', 'ποΈ', 'β', 'π', 'π', 'π', 'π€', 'π', 'βοΈ', 'π
', 'π€³'],
|
|
16
|
-
},
|
|
17
|
-
{
|
|
18
|
-
name: 'Hearts',
|
|
19
|
-
emojis: ['β€οΈ', 'π§‘', 'π', 'π', 'π', 'π', 'π€', 'π€', 'π€', 'π', 'β£οΈ', 'π', 'π', 'π', 'π', 'π', 'π', 'π', 'π', 'β₯οΈ'],
|
|
20
|
-
},
|
|
21
|
-
{
|
|
22
|
-
name: 'Food',
|
|
23
|
-
emojis: ['π', 'π', 'π', 'π', 'πΏ', 'π§', 'π₯', 'π₯', 'π³', 'π§', 'π₯', 'π§', 'π', 'π₯', 'π₯¨', 'π₯―', 'π₯', 'π§', 'π₯', 'π₯'],
|
|
24
|
-
},
|
|
25
|
-
{
|
|
26
|
-
name: 'Drinks',
|
|
27
|
-
emojis: ['β', 'π΅', 'πΆ', 'πΎ', 'π·', 'πΈ', 'πΉ', 'πΊ', 'π»', 'π₯', 'π₯', 'π₯€', 'π§', 'π§', 'π§', 'π§', 'π₯’', 'π½οΈ', 'π΄', 'π₯'],
|
|
28
|
-
},
|
|
29
|
-
{
|
|
30
|
-
name: 'Objects',
|
|
31
|
-
emojis: ['π', 'π', 'π', 'π', 'π', 'π', 'π±', 'π»', 'β', 'π·', 'π', 'π°', 'π³', 'π', 'π', 'π', 'βοΈ', 'π', 'π‘', 'π'],
|
|
32
|
-
},
|
|
33
|
-
]
|
|
34
|
-
|
|
35
|
-
export const EmojiPicker = ({ onSelect, className = '' }: EmojiPickerProps) => {
|
|
20
|
+
export const EmojiPicker = ({ onSelect, labels, className = '' }: EmojiPickerProps) => {
|
|
21
|
+
const searchLabel = labels?.search ?? DEFAULT_EMOJI_PICKER_LABELS.search
|
|
22
|
+
const noResultsLabel = labels?.noResults ?? DEFAULT_EMOJI_PICKER_LABELS.noResults
|
|
36
23
|
const [activeCategory, setActiveCategory] = useState(0)
|
|
24
|
+
const [query, setQuery] = useState('')
|
|
37
25
|
|
|
38
26
|
const handleSelect = useCallback(
|
|
39
27
|
(emoji: string) => {
|
|
@@ -42,36 +30,62 @@ export const EmojiPicker = ({ onSelect, className = '' }: EmojiPickerProps) => {
|
|
|
42
30
|
[onSelect],
|
|
43
31
|
)
|
|
44
32
|
|
|
33
|
+
// Buscando, as abas de categoria saem do caminho: o resultado atravessa todas elas, e manter uma
|
|
34
|
+
// aba destacada sugeriria que a busca estΓ‘ restrita Γ quela categoria.
|
|
35
|
+
const isSearching = query.trim().length > 0
|
|
36
|
+
const visibleEntries: readonly EmojiEntry[] = useMemo(
|
|
37
|
+
() => (isSearching ? searchEmojis(query) : EMOJI_CATEGORIES[activeCategory].entries),
|
|
38
|
+
[isSearching, query, activeCategory],
|
|
39
|
+
)
|
|
40
|
+
|
|
45
41
|
return (
|
|
46
42
|
<div className={`bg-white border border-gray-200 rounded-lg shadow-lg overflow-hidden ${className}`}>
|
|
47
|
-
<div className="
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
}`}
|
|
57
|
-
>
|
|
58
|
-
{category.emojis[0]} {category.name}
|
|
59
|
-
</button>
|
|
60
|
-
))}
|
|
43
|
+
<div className="p-2 border-b border-gray-200">
|
|
44
|
+
<input
|
|
45
|
+
type="search"
|
|
46
|
+
value={query}
|
|
47
|
+
onChange={(event) => setQuery(event.target.value)}
|
|
48
|
+
placeholder={searchLabel}
|
|
49
|
+
aria-label={searchLabel}
|
|
50
|
+
className="w-full rounded-md border border-gray-200 px-2 py-1.5 text-sm outline-none focus:border-blue-500"
|
|
51
|
+
/>
|
|
61
52
|
</div>
|
|
62
53
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
54
|
+
{isSearching ? null : (
|
|
55
|
+
<div className="flex border-b border-gray-200 overflow-x-auto">
|
|
56
|
+
{EMOJI_CATEGORIES.map((category, index) => (
|
|
57
|
+
<button
|
|
58
|
+
key={category.name}
|
|
59
|
+
onClick={() => setActiveCategory(index)}
|
|
60
|
+
className={`px-3 py-2 text-xs font-medium whitespace-nowrap border-b-2 transition-colors ${
|
|
61
|
+
activeCategory === index
|
|
62
|
+
? 'border-blue-500 text-blue-600'
|
|
63
|
+
: 'border-transparent text-gray-500 hover:text-gray-700'
|
|
64
|
+
}`}
|
|
65
|
+
>
|
|
66
|
+
{category.entries[0].emoji} {category.name}
|
|
67
|
+
</button>
|
|
68
|
+
))}
|
|
69
|
+
</div>
|
|
70
|
+
)}
|
|
71
|
+
|
|
72
|
+
{visibleEntries.length === 0 ? (
|
|
73
|
+
<p className="px-3 py-6 text-center text-sm text-gray-500">{noResultsLabel}</p>
|
|
74
|
+
) : (
|
|
75
|
+
<div className="grid grid-cols-10 gap-0.5 p-2 max-h-[240px] overflow-y-auto">
|
|
76
|
+
{visibleEntries.map((entry) => (
|
|
77
|
+
<button
|
|
78
|
+
key={entry.emoji}
|
|
79
|
+
onClick={() => handleSelect(entry.emoji)}
|
|
80
|
+
className="w-8 h-8 flex items-center justify-center text-lg hover:bg-gray-100 rounded transition-colors cursor-pointer"
|
|
81
|
+
aria-label={entry.emoji}
|
|
82
|
+
title={entry.keywords[0]}
|
|
83
|
+
>
|
|
84
|
+
{entry.emoji}
|
|
85
|
+
</button>
|
|
86
|
+
))}
|
|
87
|
+
</div>
|
|
88
|
+
)}
|
|
75
89
|
</div>
|
|
76
90
|
)
|
|
77
91
|
}
|
package/src/FileIcon.test.ts
CHANGED
|
@@ -33,6 +33,51 @@ describe('resolveFileIconExtension', () => {
|
|
|
33
33
|
})
|
|
34
34
|
|
|
35
35
|
it('devolve algo fora do mapa para tipo desconhecido, caindo no Γcone genΓ©rico', () => {
|
|
36
|
-
expect(resolveFileIconExtension('
|
|
36
|
+
expect(resolveFileIconExtension('backup', 'application/octet-stream')).toBe('octet-stream')
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
// Imagem, vΓdeo e Γ‘udio entram na biblioteca junto dos documentos β o backend linka as cinco
|
|
40
|
+
// espΓ©cies de mΓdia (image/audio/video/document/sticker), nΓ£o sΓ³ `document`.
|
|
41
|
+
const MEDIA_TYPES = [
|
|
42
|
+
{ filename: 'foto.jpg', mimeType: 'image/jpeg', expected: 'jpg' },
|
|
43
|
+
{ filename: 'prateleira.png', mimeType: 'image/png', expected: 'png' },
|
|
44
|
+
{ filename: 'video-do-produto.mp4', mimeType: 'video/mp4', expected: 'mp4' },
|
|
45
|
+
{ filename: 'antigo.3gp', mimeType: 'video/3gp', expected: '3gp' },
|
|
46
|
+
{ filename: 'musica.mp3', mimeType: 'audio/mpeg', expected: 'mp3' },
|
|
47
|
+
{ filename: 'recado.m4a', mimeType: 'audio/mp4', expected: 'm4a' },
|
|
48
|
+
{ filename: 'lista-compras.txt', mimeType: 'text/plain', expected: 'txt' },
|
|
49
|
+
{ filename: 'planilha.csv', mimeType: 'text/csv', expected: 'csv' },
|
|
50
|
+
{
|
|
51
|
+
filename: 'apresentacao.pptx',
|
|
52
|
+
mimeType: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
|
53
|
+
expected: 'pptx',
|
|
54
|
+
},
|
|
55
|
+
]
|
|
56
|
+
|
|
57
|
+
for (const testCase of MEDIA_TYPES) {
|
|
58
|
+
it(`resolve ${testCase.expected}`, () => {
|
|
59
|
+
expect(resolveFileIconExtension(testCase.filename, testCase.mimeType)).toBe(testCase.expected)
|
|
60
|
+
})
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Γudio e sticker chegam da Meta sem nome de arquivo: o backend salva o id da mΓdia como rΓ³tulo,
|
|
64
|
+
// entΓ£o a ΓΊnica pista de tipo Γ© o mimeType.
|
|
65
|
+
it('resolve pela famΓlia quando o nome Γ© o id da mΓdia, sem extensΓ£o', () => {
|
|
66
|
+
expect(resolveFileIconExtension('wamid-abc123', 'image/webp')).toBe('image')
|
|
67
|
+
expect(resolveFileIconExtension('wamid-abc123', 'video/mp4')).toBe('video')
|
|
68
|
+
expect(resolveFileIconExtension('wamid-abc123', 'audio/aac')).toBe('audio')
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
// `audio/ogg; codecs=opus` Γ© o formato do Γ‘udio de WhatsApp. Com o parΓ’metro colado, o subtipo
|
|
72
|
+
// viria "ogg; codecs=opus" e o Γ‘udio cairia no Γcone genΓ©rico.
|
|
73
|
+
it('descarta parΓ’metro do mimeType', () => {
|
|
74
|
+
expect(resolveFileIconExtension('wamid-audio', 'audio/ogg; codecs=opus')).toBe('audio')
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
// Pelo subtipo, `audio/mp4` casaria a chave `mp4` β que Γ© vΓdeo. A famΓlia vem primeiro
|
|
78
|
+
// justamente para um m4a nΓ£o aparecer com Γcone de filme.
|
|
79
|
+
it('nΓ£o confunde audio/mp4 com vΓdeo', () => {
|
|
80
|
+
expect(resolveFileIconExtension(undefined, 'audio/mp4')).toBe('audio')
|
|
81
|
+
expect(resolveFileIconExtension(undefined, 'video/mp4')).toBe('video')
|
|
37
82
|
})
|
|
38
83
|
})
|
package/src/FileIcon.tsx
CHANGED
|
@@ -1,4 +1,13 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
FileArchive,
|
|
3
|
+
FileAudio,
|
|
4
|
+
FileImage,
|
|
5
|
+
FileSpreadsheet,
|
|
6
|
+
FileText,
|
|
7
|
+
FileVideo,
|
|
8
|
+
File as FileGeneric,
|
|
9
|
+
Presentation,
|
|
10
|
+
} from 'lucide-react'
|
|
2
11
|
|
|
3
12
|
import { cn } from './lib/cn'
|
|
4
13
|
|
|
@@ -9,26 +18,83 @@ export interface FileIconProps {
|
|
|
9
18
|
className?: string
|
|
10
19
|
}
|
|
11
20
|
|
|
12
|
-
|
|
21
|
+
type IconStyle = { Icon: typeof FileText; colorClass: string }
|
|
22
|
+
|
|
23
|
+
// Um estilo por famΓlia, reaproveitado por todas as extensΓ΅es dela: assim o Γcone de um `.jpg`
|
|
24
|
+
// resolvido pelo nome Γ© o mesmo de um `image/jpeg` resolvido pelo mimeType. Com cor por extensΓ£o, a
|
|
25
|
+
// mesma foto trocaria de cor conforme o dado que chegou junto.
|
|
26
|
+
const IMAGE_STYLE: IconStyle = { Icon: FileImage, colorClass: 'text-violet-500' }
|
|
27
|
+
const VIDEO_STYLE: IconStyle = { Icon: FileVideo, colorClass: 'text-fuchsia-500' }
|
|
28
|
+
const AUDIO_STYLE: IconStyle = { Icon: FileAudio, colorClass: 'text-amber-500' }
|
|
29
|
+
const SHEET_STYLE: IconStyle = { Icon: FileSpreadsheet, colorClass: 'text-green-600' }
|
|
30
|
+
const WORD_STYLE: IconStyle = { Icon: FileText, colorClass: 'text-blue-500' }
|
|
31
|
+
const SLIDES_STYLE: IconStyle = { Icon: Presentation, colorClass: 'text-orange-600' }
|
|
32
|
+
const TEXT_STYLE: IconStyle = { Icon: FileText, colorClass: 'text-gray-500' }
|
|
33
|
+
|
|
34
|
+
const EXTENSION_STYLE: Record<string, IconStyle> = {
|
|
13
35
|
pdf: { Icon: FileText, colorClass: 'text-red-500' },
|
|
14
|
-
doc:
|
|
15
|
-
docx:
|
|
16
|
-
xls:
|
|
17
|
-
xlsx:
|
|
36
|
+
doc: WORD_STYLE,
|
|
37
|
+
docx: WORD_STYLE,
|
|
38
|
+
xls: SHEET_STYLE,
|
|
39
|
+
xlsx: SHEET_STYLE,
|
|
40
|
+
csv: SHEET_STYLE,
|
|
41
|
+
ppt: SLIDES_STYLE,
|
|
42
|
+
pptx: SLIDES_STYLE,
|
|
18
43
|
zip: { Icon: FileArchive, colorClass: 'text-orange-500' },
|
|
44
|
+
txt: TEXT_STYLE,
|
|
45
|
+
plain: TEXT_STYLE,
|
|
46
|
+
|
|
47
|
+
image: IMAGE_STYLE,
|
|
48
|
+
jpg: IMAGE_STYLE,
|
|
49
|
+
jpeg: IMAGE_STYLE,
|
|
50
|
+
png: IMAGE_STYLE,
|
|
51
|
+
webp: IMAGE_STYLE,
|
|
52
|
+
gif: IMAGE_STYLE,
|
|
53
|
+
heic: IMAGE_STYLE,
|
|
54
|
+
|
|
55
|
+
video: VIDEO_STYLE,
|
|
56
|
+
mp4: VIDEO_STYLE,
|
|
57
|
+
'3gp': VIDEO_STYLE,
|
|
58
|
+
'3gpp': VIDEO_STYLE,
|
|
59
|
+
mov: VIDEO_STYLE,
|
|
60
|
+
webm: VIDEO_STYLE,
|
|
61
|
+
|
|
62
|
+
audio: AUDIO_STYLE,
|
|
63
|
+
mp3: AUDIO_STYLE,
|
|
64
|
+
mpeg: AUDIO_STYLE,
|
|
65
|
+
ogg: AUDIO_STYLE,
|
|
66
|
+
oga: AUDIO_STYLE,
|
|
67
|
+
opus: AUDIO_STYLE,
|
|
68
|
+
aac: AUDIO_STYLE,
|
|
69
|
+
amr: AUDIO_STYLE,
|
|
70
|
+
m4a: AUDIO_STYLE,
|
|
71
|
+
wav: AUDIO_STYLE,
|
|
19
72
|
}
|
|
20
73
|
|
|
74
|
+
const MEDIA_FAMILIES = new Set(['image', 'video', 'audio'])
|
|
75
|
+
|
|
21
76
|
/**
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
77
|
+
* A chave de estilo do arquivo, na ordem em que cada dado Γ© confiΓ‘vel.
|
|
78
|
+
*
|
|
79
|
+
* 1. **extensΓ£o do nome** β o mimeType do Office Γ© longo (`β¦wordprocessingml.document`) e nunca
|
|
80
|
+
* casaria, entΓ£o quem olhasse sΓ³ o mimeType perderia o Γcone de Word e de Excel;
|
|
81
|
+
* 2. **famΓlia do mimeType** (`image/`, `video/`, `audio/`) β tem de vir ANTES do subtipo por causa
|
|
82
|
+
* de `audio/mp4`: pelo subtipo, um Γ‘udio m4a ganharia o Γcone de vΓdeo;
|
|
83
|
+
* 3. **subtipo** β cobre `application/pdf` e `application/zip`, que chegam sem nome de arquivo.
|
|
84
|
+
*
|
|
85
|
+
* Γudio de WhatsApp chega como `audio/ogg; codecs=opus`; o parΓ’metro depois do `;` Γ© descartado,
|
|
86
|
+
* senΓ£o o subtipo viria `ogg; codecs=opus` e nΓ£o casaria nada.
|
|
25
87
|
*
|
|
26
88
|
* Exportada para teste: Γ© a regra que jΓ‘ regrediu uma vez no painel de documentos.
|
|
27
89
|
*/
|
|
28
90
|
export function resolveFileIconExtension(filename?: string, mimeType?: string): string {
|
|
29
91
|
const fromFilename = filename?.split('.').pop()?.toLowerCase()
|
|
30
92
|
if (fromFilename && EXTENSION_STYLE[fromFilename]) return fromFilename
|
|
31
|
-
|
|
93
|
+
|
|
94
|
+
const [family, subtype] = (mimeType ?? '').split(';')[0]!.toLowerCase().split('/')
|
|
95
|
+
if (family && MEDIA_FAMILIES.has(family)) return family
|
|
96
|
+
|
|
97
|
+
return subtype ?? ''
|
|
32
98
|
}
|
|
33
99
|
|
|
34
100
|
// Melhoria sobre a paridade da bolha de documento (T6.7 usava um ΓΊnico Γcone genΓ©rico
|
|
@@ -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
|
+
}
|
package/src/Lightbox.tsx
CHANGED
|
@@ -1,16 +1,31 @@
|
|
|
1
|
+
export interface LightboxLabels {
|
|
2
|
+
/** Texto alternativo quando a imagem nΓ£o tem legenda β sem ele o leitor de tela anuncia a URL. */
|
|
3
|
+
imageAlt: string
|
|
4
|
+
close: string
|
|
5
|
+
}
|
|
6
|
+
|
|
1
7
|
export interface LightboxProps {
|
|
2
8
|
imageUrl: string
|
|
3
9
|
caption?: string
|
|
4
10
|
onClose: () => void
|
|
11
|
+
labels?: Partial<LightboxLabels>
|
|
5
12
|
}
|
|
6
13
|
|
|
7
|
-
export
|
|
14
|
+
export const DEFAULT_LIGHTBOX_LABELS: LightboxLabels = {
|
|
15
|
+
imageAlt: 'Imagem',
|
|
16
|
+
close: 'Fechar',
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function Lightbox({ imageUrl, caption, onClose, labels }: LightboxProps) {
|
|
20
|
+
const imageAltLabel = labels?.imageAlt ?? DEFAULT_LIGHTBOX_LABELS.imageAlt
|
|
21
|
+
const closeLabel = labels?.close ?? DEFAULT_LIGHTBOX_LABELS.close
|
|
22
|
+
|
|
8
23
|
return (
|
|
9
24
|
<div className="fixed inset-0 z-50 bg-black/85 flex items-center justify-center p-4" onClick={onClose}>
|
|
10
25
|
<div className="max-w-[90vw] max-h-[90vh] flex flex-col items-center" onClick={(e) => e.stopPropagation()}>
|
|
11
|
-
<img src={imageUrl} alt={caption ??
|
|
26
|
+
<img src={imageUrl} alt={caption ?? imageAltLabel} className="max-w-full max-h-[80vh] object-contain rounded-lg" />
|
|
12
27
|
{caption && <p className="text-white text-sm mt-3 text-center">{caption}</p>}
|
|
13
|
-
<button onClick={onClose} className="mt-4 px-4 py-2 bg-white/20 text-white rounded-lg hover:bg-white/30 transition-colors">
|
|
28
|
+
<button onClick={onClose} className="mt-4 px-4 py-2 bg-white/20 text-white rounded-lg hover:bg-white/30 transition-colors">{closeLabel}</button>
|
|
14
29
|
</div>
|
|
15
30
|
</div>
|
|
16
31
|
)
|
package/src/MessageBubble.tsx
CHANGED
|
@@ -1,12 +1,15 @@
|
|
|
1
|
-
import { useState } from 'react'
|
|
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'
|
|
11
|
+
import { createMediaUrlResolver } from './lib/createMediaUrlResolver'
|
|
12
|
+
import { useConversations } from './providers/ConversationsProvider'
|
|
10
13
|
import { formatTimestamp, formatDateTime } from './lib/format'
|
|
11
14
|
|
|
12
15
|
export interface MessageBubbleProps {
|
|
@@ -17,7 +20,16 @@ export interface MessageBubbleProps {
|
|
|
17
20
|
isSelecting?: boolean
|
|
18
21
|
isSelected?: boolean
|
|
19
22
|
onToggleSelect?: () => void
|
|
23
|
+
/**
|
|
24
|
+
* Como buscar a mΓdia da mensagem. Ausente, o balΓ£o usa o `ConversationsApi` do
|
|
25
|
+
* `ConversationsProvider` β passe apenas para sobrescrever (cache prΓ³prio, CDN, proxy do host).
|
|
26
|
+
*/
|
|
20
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
|
|
21
33
|
className?: string
|
|
22
34
|
}
|
|
23
35
|
|
|
@@ -39,15 +51,26 @@ const MEDIA_TYPES = new Set(['image', 'audio', 'video', 'document', 'sticker'])
|
|
|
39
51
|
// tailwind.config do host expondo as cores `whatsapp.*` β ver Wallpaper.tsx e T6.2.
|
|
40
52
|
export function MessageBubble({
|
|
41
53
|
message, isMine, senderName, isFirstInGroup = true, isSelecting = false, isSelected = false, onToggleSelect,
|
|
42
|
-
onResolveMediaUrl, className,
|
|
54
|
+
onResolveMediaUrl, onInteractiveSelect, className,
|
|
43
55
|
}: MessageBubbleProps) {
|
|
44
56
|
const { bubble, selection } = useConversationLocales()
|
|
45
57
|
const [lightboxSrc, setLightboxSrc] = useState<string | null>(null)
|
|
58
|
+
const context = useConversations()
|
|
59
|
+
|
|
60
|
+
// Resolvedor pelo contexto quando o host nΓ£o passa um: o contrato jΓ‘ declara `getDocumentUrl` e
|
|
61
|
+
// `getMediaProxyUrl`, entΓ£o exigir que cada projeto ligasse esse fio sΓ³ garantia que a mΓdia nΓ£o
|
|
62
|
+
// carregasse em quem esquecesse. `useMemo` porque o resolvedor Γ© a identidade de que o
|
|
63
|
+
// `MediaRenderer` depende para nΓ£o refazer busca a cada render.
|
|
64
|
+
const resolveMediaUrl = useMemo(
|
|
65
|
+
() => onResolveMediaUrl ?? (context?.api ? createMediaUrlResolver(context.api) : undefined),
|
|
66
|
+
[onResolveMediaUrl, context?.api],
|
|
67
|
+
)
|
|
46
68
|
|
|
47
69
|
const bubbleColor = BUBBLE_COLOR[message.sender] ?? BUBBLE_COLOR.customer
|
|
48
70
|
const hasError = message.status === 'failed'
|
|
49
71
|
const isMedia = MEDIA_TYPES.has(message.type)
|
|
50
72
|
const isTemplate = message.type === 'template'
|
|
73
|
+
const isInteractive = message.type === 'interactive'
|
|
51
74
|
const displayName = message.sender === 'agent' && senderName ? senderName : bubble[message.sender] ?? message.sender
|
|
52
75
|
|
|
53
76
|
const tooltipText = message.status === 'read' && message.readAt
|
|
@@ -115,7 +138,11 @@ export function MessageBubble({
|
|
|
115
138
|
)}
|
|
116
139
|
|
|
117
140
|
{isMedia ? (
|
|
118
|
-
<MediaRenderer message={message} onLightbox={setLightboxSrc} onResolveUrl={
|
|
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} />
|
|
119
146
|
) : (
|
|
120
147
|
<>
|
|
121
148
|
{isTemplate && (
|
package/src/MessageComposer.tsx
CHANGED
|
@@ -3,7 +3,20 @@ import type { ConversationsFeatures } from './types'
|
|
|
3
3
|
import { cn } from './lib/cn'
|
|
4
4
|
import { EmojiPicker } from './EmojiPicker'
|
|
5
5
|
|
|
6
|
+
export interface MessageComposerLabels {
|
|
7
|
+
emoji: string
|
|
8
|
+
attach: string
|
|
9
|
+
send: string
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export const DEFAULT_MESSAGE_COMPOSER_LABELS: MessageComposerLabels = {
|
|
13
|
+
emoji: 'Emoji',
|
|
14
|
+
attach: 'Anexar',
|
|
15
|
+
send: 'Enviar',
|
|
16
|
+
}
|
|
17
|
+
|
|
6
18
|
export interface MessageComposerProps {
|
|
19
|
+
labels?: Partial<MessageComposerLabels>
|
|
7
20
|
onSend: (text: string) => void
|
|
8
21
|
onAttach?: (file: File) => void
|
|
9
22
|
value?: string
|
|
@@ -22,8 +35,22 @@ export interface MessageComposerClassNames {
|
|
|
22
35
|
field: string
|
|
23
36
|
}
|
|
24
37
|
|
|
25
|
-
|
|
26
|
-
|
|
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(',')
|
|
27
54
|
|
|
28
55
|
interface FilePreview {
|
|
29
56
|
file: File
|
|
@@ -42,7 +69,11 @@ export const MessageComposer = ({
|
|
|
42
69
|
acceptedFileTypes = DEFAULT_ACCEPTED_FILE_TYPES,
|
|
43
70
|
className,
|
|
44
71
|
classNames,
|
|
72
|
+
labels,
|
|
45
73
|
}: MessageComposerProps) => {
|
|
74
|
+
const emojiLabel = labels?.emoji ?? DEFAULT_MESSAGE_COMPOSER_LABELS.emoji
|
|
75
|
+
const attachLabel = labels?.attach ?? DEFAULT_MESSAGE_COMPOSER_LABELS.attach
|
|
76
|
+
const sendLabel = labels?.send ?? DEFAULT_MESSAGE_COMPOSER_LABELS.send
|
|
46
77
|
const [internalText, setInternalText] = useState('')
|
|
47
78
|
const [showEmoji, setShowEmoji] = useState(false)
|
|
48
79
|
const [attachments, setAttachments] = useState<FilePreview[]>([])
|
|
@@ -168,7 +199,7 @@ export const MessageComposer = ({
|
|
|
168
199
|
<div className={cn('flex items-end gap-1.5 rounded-xl bg-white px-3 py-2', classNames?.field)}>
|
|
169
200
|
{showEmojiButton && (
|
|
170
201
|
<div className="relative flex-shrink-0">
|
|
171
|
-
<button onClick={() => setShowEmoji(v => !v)} className="w-9 h-9 flex items-center justify-center rounded-full text-gray-500 hover:bg-gray-200 transition-colors" aria-label=
|
|
202
|
+
<button onClick={() => setShowEmoji(v => !v)} className="w-9 h-9 flex items-center justify-center rounded-full text-gray-500 hover:bg-gray-200 transition-colors" aria-label={emojiLabel}>
|
|
172
203
|
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><circle cx="12" cy="12" r="10"/><path d="M8 14s1.5 2 4 2 4-2 4-2"/><circle cx="9" cy="9" r="0.5" fill="currentColor"/><circle cx="15" cy="9" r="0.5" fill="currentColor"/></svg>
|
|
173
204
|
</button>
|
|
174
205
|
{showEmoji && (
|
|
@@ -194,7 +225,7 @@ export const MessageComposer = ({
|
|
|
194
225
|
{showAttachButton && (
|
|
195
226
|
<>
|
|
196
227
|
<input ref={fileInputRef} type="file" multiple accept={acceptedFileTypes} onChange={handleFileChange} className="hidden" />
|
|
197
|
-
<button onClick={() => fileInputRef.current?.click()} className="w-9 h-9 flex items-center justify-center rounded-full text-gray-500 hover:bg-gray-200 flex-shrink-0 transition-colors" aria-label=
|
|
228
|
+
<button onClick={() => fileInputRef.current?.click()} className="w-9 h-9 flex items-center justify-center rounded-full text-gray-500 hover:bg-gray-200 flex-shrink-0 transition-colors" aria-label={attachLabel}>
|
|
198
229
|
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg>
|
|
199
230
|
</button>
|
|
200
231
|
</>
|
|
@@ -208,7 +239,7 @@ export const MessageComposer = ({
|
|
|
208
239
|
? 'bg-[#00a884] text-white hover:bg-[#06cf9c] shadow-sm'
|
|
209
240
|
: 'bg-gray-200 text-gray-400 cursor-not-allowed'
|
|
210
241
|
}`}
|
|
211
|
-
aria-label=
|
|
242
|
+
aria-label={sendLabel}
|
|
212
243
|
>
|
|
213
244
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor"><path d="M2.01 21L23 12 2.01 3 2 10l15 2-15 2z"/></svg>
|
|
214
245
|
</button>
|