@adatechnology/conversations-ui 0.1.0-rc.5 → 0.1.0-rc.6
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-LITPZCWW.js +1465 -0
- package/dist/index.d.ts +135 -18
- package/dist/index.js +228 -589
- package/dist/preview/index.d.ts +82 -7
- package/dist/preview/index.js +314 -11
- package/dist/{types-C2Yexi8A.d.ts → types-CeixG2Z9.d.ts} +28 -1
- package/package.json +1 -1
- package/src/Avatar.tsx +13 -2
- package/src/ConversationListItem.tsx +18 -2
- package/src/DocumentsLibrary.tsx +322 -0
- package/src/FileIcon.test.ts +46 -1
- package/src/FileIcon.tsx +76 -10
- package/src/Lightbox.tsx +18 -3
- package/src/MessageBubble.tsx +18 -2
- package/src/MessageComposer.tsx +20 -3
- package/src/WhatsAppMessageEditor.tsx +28 -4
- package/src/index.ts +17 -10
- package/src/lib/createMediaUrlResolver.ts +33 -0
- package/src/preview/MediaTypesPreview.tsx +87 -0
- package/src/preview/createMockConversationsApi.ts +62 -8
- package/src/preview/index.ts +5 -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/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>
|
package/src/index.ts
CHANGED
|
@@ -3,16 +3,16 @@ export { ConversationWallpaper } from './Wallpaper'
|
|
|
3
3
|
export { ConversationLocalesProvider, useConversationLocales } from './ConversationLocalesProvider'
|
|
4
4
|
export { AudioPlayer } from './AudioPlayer'
|
|
5
5
|
export { EmojiPicker } from './EmojiPicker'
|
|
6
|
-
export { MessageComposer } from './MessageComposer'
|
|
7
|
-
export { WhatsAppMessageEditor } from './WhatsAppMessageEditor'
|
|
6
|
+
export { MessageComposer, DEFAULT_MESSAGE_COMPOSER_LABELS } from './MessageComposer'
|
|
7
|
+
export { WhatsAppMessageEditor, DEFAULT_WHATSAPP_MESSAGE_EDITOR_LABELS } from './WhatsAppMessageEditor'
|
|
8
8
|
export { SimpleEmojiPicker } from './SimpleEmojiPicker'
|
|
9
9
|
export { DateDivider } from './DateDivider'
|
|
10
|
-
export { Avatar } from './Avatar'
|
|
11
|
-
export { ConversationListItem } from './ConversationListItem'
|
|
10
|
+
export { Avatar, DEFAULT_AVATAR_LABELS } from './Avatar'
|
|
11
|
+
export { ConversationListItem, DEFAULT_CONVERSATION_LIST_ITEM_LABELS } from './ConversationListItem'
|
|
12
12
|
export { ToastProvider, useToast, toast } from './Toast'
|
|
13
13
|
|
|
14
14
|
export { StatusTicks } from './StatusTicks'
|
|
15
|
-
export { Lightbox } from './Lightbox'
|
|
15
|
+
export { Lightbox, DEFAULT_LIGHTBOX_LABELS } from './Lightbox'
|
|
16
16
|
export { MediaRenderer } from './MediaRenderer'
|
|
17
17
|
export { FileIcon } from './FileIcon'
|
|
18
18
|
export { MessageText } from './MessageText'
|
|
@@ -65,6 +65,10 @@ export type {
|
|
|
65
65
|
} from './ConversationContextPanel'
|
|
66
66
|
export { WindowExpiredNotice, isWindowBlocking, DEFAULT_WINDOW_EXPIRED_LABELS } from './WindowExpiredNotice'
|
|
67
67
|
export type { WindowExpiredNoticeProps, WindowExpiredNoticeLabels } from './WindowExpiredNotice'
|
|
68
|
+
export { DocumentsLibrary, DEFAULT_DOCUMENTS_LIBRARY_LABELS } from './DocumentsLibrary'
|
|
69
|
+
export type { DocumentsLibraryProps, DocumentsLibraryLabels, DocumentsLibraryClassNames } from './DocumentsLibrary'
|
|
70
|
+
export { DOCUMENT_SOURCE_FILTER } from './ConversationDocumentsPanel'
|
|
71
|
+
export type { DocumentSourceFilter } from './ConversationDocumentsPanel'
|
|
68
72
|
export type { ConversationDocumentsPanelClassNames } from './ConversationDocumentsPanel'
|
|
69
73
|
export { ConversationDocumentsPanel, DEFAULT_CONVERSATION_DOCUMENTS_LABELS } from './ConversationDocumentsPanel'
|
|
70
74
|
export type { ConversationDocumentsPanelProps, ConversationDocumentsPanelLabels } from './ConversationDocumentsPanel'
|
|
@@ -118,6 +122,8 @@ export type {
|
|
|
118
122
|
ConversationDocument,
|
|
119
123
|
ConversationPage,
|
|
120
124
|
ConversationDocumentPage,
|
|
125
|
+
CompanyDocument,
|
|
126
|
+
CompanyDocumentPage,
|
|
121
127
|
ConversationTemplate,
|
|
122
128
|
ListConversationsParams,
|
|
123
129
|
ListDocumentsParams,
|
|
@@ -129,14 +135,14 @@ export type { ConversationWallpaperProps } from './Wallpaper'
|
|
|
129
135
|
export type { ConversationLocales, ConversationLocalesProviderProps } from './ConversationLocalesProvider'
|
|
130
136
|
export type { AudioPlayerProps } from './AudioPlayer'
|
|
131
137
|
export type { EmojiPickerProps } from './EmojiPicker'
|
|
132
|
-
export type { MessageComposerProps, MessageComposerClassNames } from './MessageComposer'
|
|
133
|
-
export type { WhatsAppMessageEditorProps } from './WhatsAppMessageEditor'
|
|
138
|
+
export type { MessageComposerProps, MessageComposerClassNames, MessageComposerLabels } from './MessageComposer'
|
|
139
|
+
export type { WhatsAppMessageEditorProps, WhatsAppMessageEditorLabels } from './WhatsAppMessageEditor'
|
|
134
140
|
export type { SimpleEmojiPickerProps } from './SimpleEmojiPicker'
|
|
135
141
|
export type { DateDividerProps, DateDividerClassNames } from './DateDivider'
|
|
136
|
-
export type { AvatarProps } from './Avatar'
|
|
137
|
-
export type { ConversationListItemProps } from './ConversationListItem'
|
|
142
|
+
export type { AvatarProps, AvatarLabels } from './Avatar'
|
|
143
|
+
export type { ConversationListItemProps, ConversationListItemLabels } from './ConversationListItem'
|
|
138
144
|
export type { StatusTicksProps } from './StatusTicks'
|
|
139
|
-
export type { LightboxProps } from './Lightbox'
|
|
145
|
+
export type { LightboxProps, LightboxLabels } from './Lightbox'
|
|
140
146
|
export type { MediaRendererProps, ResolveMediaUrl } from './MediaRenderer'
|
|
141
147
|
export type { FileIconProps } from './FileIcon'
|
|
142
148
|
export type { MessageTextProps } from './MessageText'
|
|
@@ -168,3 +174,4 @@ export type { UseConversationContextResult } from './hooks/useConversationContex
|
|
|
168
174
|
export type { UseConversationDocumentsParams, UseConversationDocumentsResult } from './hooks/useConversationDocuments'
|
|
169
175
|
export type { ConversationRealtimeHandler } from './hooks/useConversationRealtime'
|
|
170
176
|
export type { AsyncResourceState } from './hooks/useAsyncResource'
|
|
177
|
+
export { createMediaUrlResolver } from './lib/createMediaUrlResolver'
|
|
@@ -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,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bancada de teste manual de mídia — montável em uma linha por qualquer projeto que adote o SDK.
|
|
3
|
+
*
|
|
4
|
+
* Existe porque o defeito que ela pega não é pegável por teste automatizado: "o PDF abre?" depende
|
|
5
|
+
* do leitor do navegador, "o vídeo toca?" do decodificador, e "a aba abre?" da política do Chrome
|
|
6
|
+
* sobre `data:` URL. Teste unitário confere bytes; só o olho confere que o arquivo abre. Sem uma
|
|
7
|
+
* superfície pronta no pacote, cada projeto teria de montar a sua — e, na prática, nenhum montava.
|
|
8
|
+
*
|
|
9
|
+
* Traz o próprio store, o próprio mock e o próprio provider: o host não injeta nada. E não passa
|
|
10
|
+
* `onResolveMediaUrl` em lugar nenhum de propósito — é o `MessageBubble` resolvendo mídia pelo
|
|
11
|
+
* `ConversationsApi` do contexto, então se essa resolução automática quebrar, esta tela mostra.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { useMemo } from 'react'
|
|
15
|
+
import { ConversationsProvider } from '../providers/ConversationsProvider'
|
|
16
|
+
import { DocumentsLibrary } from '../DocumentsLibrary'
|
|
17
|
+
import { ConversationDocumentsPanel } from '../ConversationDocumentsPanel'
|
|
18
|
+
import { MessageBubble } from '../MessageBubble'
|
|
19
|
+
import { ConversationWallpaper } from '../Wallpaper'
|
|
20
|
+
import { createMockConversationsApi } from './createMockConversationsApi'
|
|
21
|
+
import { createMockSSEProvider } from './createMockSSEProvider'
|
|
22
|
+
import { createPreviewStore } from './previewStore'
|
|
23
|
+
import { PREVIEW_CONVERSATIONS, PREVIEW_DOCUMENTS, PREVIEW_MESSAGES } from './previewFixtures'
|
|
24
|
+
|
|
25
|
+
/** A conversa do fixture que carrega uma mensagem de cada tipo aceito. */
|
|
26
|
+
export const MEDIA_TYPES_CONVERSATION_ID = '5511944443333'
|
|
27
|
+
|
|
28
|
+
export type MediaTypesPreviewProps = {
|
|
29
|
+
/** Outra conversa do fixture, se o projeto tiver acrescentado a sua. */
|
|
30
|
+
conversationId?: string
|
|
31
|
+
className?: string
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function MediaTypesPreview({
|
|
35
|
+
conversationId = MEDIA_TYPES_CONVERSATION_ID,
|
|
36
|
+
className,
|
|
37
|
+
}: MediaTypesPreviewProps) {
|
|
38
|
+
const store = useMemo(
|
|
39
|
+
() => createPreviewStore({ conversations: PREVIEW_CONVERSATIONS, messages: PREVIEW_MESSAGES }),
|
|
40
|
+
[],
|
|
41
|
+
)
|
|
42
|
+
const api = useMemo(() => createMockConversationsApi({ store }), [store])
|
|
43
|
+
const sse = useMemo(() => createMockSSEProvider({ store }), [store])
|
|
44
|
+
|
|
45
|
+
const messages = PREVIEW_MESSAGES[conversationId] ?? []
|
|
46
|
+
const documents = PREVIEW_DOCUMENTS[conversationId] ?? []
|
|
47
|
+
const mimeTypes = [...new Set(documents.map((document) => document.mimeType))]
|
|
48
|
+
|
|
49
|
+
return (
|
|
50
|
+
<ConversationsProvider api={api} sse={sse}>
|
|
51
|
+
<div className={className}>
|
|
52
|
+
<header className="border-b px-4 py-3 dark:border-gray-700">
|
|
53
|
+
<h1 className="text-lg font-semibold">Teste manual de mídia</h1>
|
|
54
|
+
<p className="text-sm text-gray-500">
|
|
55
|
+
{documents.length} arquivos, {mimeTypes.length} tipos. Clique no olho para abrir em aba nova e no
|
|
56
|
+
botão da bolha para carregar a mídia na thread — é o que teste automatizado não vê.
|
|
57
|
+
</p>
|
|
58
|
+
</header>
|
|
59
|
+
|
|
60
|
+
<div className="grid gap-4 p-4 lg:grid-cols-2">
|
|
61
|
+
<section className="space-y-3">
|
|
62
|
+
<h2 className="text-sm font-semibold uppercase tracking-wide text-gray-500">Biblioteca da empresa</h2>
|
|
63
|
+
{/* Sem paginar: a bancada serve para ver TODOS os tipos de uma vez. */}
|
|
64
|
+
<DocumentsLibrary perPage={documents.length || 20} />
|
|
65
|
+
</section>
|
|
66
|
+
|
|
67
|
+
<section className="space-y-3">
|
|
68
|
+
<h2 className="text-sm font-semibold uppercase tracking-wide text-gray-500">Painel da conversa</h2>
|
|
69
|
+
<ConversationDocumentsPanel conversationId={conversationId} open perPage={documents.length || 20} />
|
|
70
|
+
|
|
71
|
+
<h2 className="text-sm font-semibold uppercase tracking-wide text-gray-500">Bolhas na thread</h2>
|
|
72
|
+
<ConversationWallpaper className="max-h-[70vh] overflow-y-auto rounded-lg px-3 py-2">
|
|
73
|
+
{messages.map((message, index) => (
|
|
74
|
+
<MessageBubble
|
|
75
|
+
key={message.id}
|
|
76
|
+
message={message}
|
|
77
|
+
isMine={message.direction === 'outbound'}
|
|
78
|
+
isFirstInGroup={index === 0 || messages[index - 1]?.sender !== message.sender}
|
|
79
|
+
/>
|
|
80
|
+
))}
|
|
81
|
+
</ConversationWallpaper>
|
|
82
|
+
</section>
|
|
83
|
+
</div>
|
|
84
|
+
</div>
|
|
85
|
+
</ConversationsProvider>
|
|
86
|
+
)
|
|
87
|
+
}
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
|
|
10
10
|
import type { MessagePayload } from '../types'
|
|
11
11
|
import type {
|
|
12
|
+
CompanyDocumentPage,
|
|
12
13
|
ConversationDocumentPage,
|
|
13
14
|
ConversationPage,
|
|
14
15
|
ConversationTemplate,
|
|
@@ -16,12 +17,9 @@ import type {
|
|
|
16
17
|
ListConversationsParams,
|
|
17
18
|
} from '../providers/types'
|
|
18
19
|
import { PREVIEW_DOCUMENTS } from './previewFixtures'
|
|
20
|
+
import { previewFileBase64, previewFileUrl } from './previewMediaSource'
|
|
19
21
|
import type { PreviewStore } from './previewStore'
|
|
20
22
|
|
|
21
|
-
// PNG 1x1 transparente: o suficiente para o MediaRenderer ter algo válido para desenhar.
|
|
22
|
-
const PREVIEW_IMAGE_BASE64 =
|
|
23
|
-
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYGD4DwABBAEAX+XyEgAAAABJRU5ErkJggg=='
|
|
24
|
-
|
|
25
23
|
export type CreateMockConversationsApiParams = {
|
|
26
24
|
readonly store: PreviewStore
|
|
27
25
|
readonly latencyMs?: number
|
|
@@ -178,12 +176,68 @@ export function createMockConversationsApi(params: CreateMockConversationsApiPar
|
|
|
178
176
|
})
|
|
179
177
|
},
|
|
180
178
|
|
|
181
|
-
|
|
182
|
-
|
|
179
|
+
/** Junta as bibliotecas de todas as conversas do fixture, com a origem de cada arquivo. */
|
|
180
|
+
getAllDocuments(documentParams): Promise<CompanyDocumentPage> {
|
|
181
|
+
return withLatency(() => {
|
|
182
|
+
let all = Object.entries(PREVIEW_DOCUMENTS).flatMap(([conversationId, docs]) =>
|
|
183
|
+
docs.map((document) => ({ ...document, conversationId })),
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
// Mesma regra do backend (`companyDocumentSearch`): o termo casa nome do arquivo OU
|
|
187
|
+
// telefone da conversa, e o telefone só pelos dígitos — o preview mostra o número
|
|
188
|
+
// formatado, então é assim que o atendente vai colá-lo na busca.
|
|
189
|
+
const search = documentParams?.search?.trim().toLowerCase()
|
|
190
|
+
if (search) {
|
|
191
|
+
const digits = search.replace(/\D/g, '')
|
|
192
|
+
all = all.filter(
|
|
193
|
+
(document) =>
|
|
194
|
+
document.filename.toLowerCase().includes(search) ||
|
|
195
|
+
(digits !== '' && document.conversationId.includes(digits)),
|
|
196
|
+
)
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const source = documentParams?.source
|
|
200
|
+
if (source === 'team') {
|
|
201
|
+
all = all.filter((document) => document.source === 'agent' || document.source === 'bot')
|
|
202
|
+
} else if (source) {
|
|
203
|
+
all = all.filter((document) => document.source === source)
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
all.sort((left, right) =>
|
|
207
|
+
documentParams?.sortDirection === 'asc'
|
|
208
|
+
? left.linkedAt.localeCompare(right.linkedAt)
|
|
209
|
+
: right.linkedAt.localeCompare(left.linkedAt),
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
const total = all.length
|
|
213
|
+
const limit = documentParams?.limit ?? total
|
|
214
|
+
const page = documentParams?.page ?? 1
|
|
215
|
+
return { documents: all.slice((page - 1) * limit, page * limit), total }
|
|
216
|
+
})
|
|
183
217
|
},
|
|
184
218
|
|
|
185
|
-
|
|
186
|
-
|
|
219
|
+
// Devolve os bytes DO TIPO do documento, não uma imagem para tudo: antes, abrir um PDF entregava
|
|
220
|
+
// um PNG rotulado `application/pdf` e o leitor recusava o arquivo. O `uploadId` é a única pista
|
|
221
|
+
// que o contrato dá, então o tipo vem da própria biblioteca.
|
|
222
|
+
getDocumentUrl(uploadId): Promise<string> {
|
|
223
|
+
return withLatency(() => {
|
|
224
|
+
const found = Object.values(PREVIEW_DOCUMENTS)
|
|
225
|
+
.flat()
|
|
226
|
+
.find((document) => document.id === uploadId)
|
|
227
|
+
return previewFileUrl(found?.mimeType, found?.filename)
|
|
228
|
+
})
|
|
229
|
+
},
|
|
230
|
+
|
|
231
|
+
// Caminho da mídia ainda não ingerida: o backend busca na Meta e devolve base64. Resolve pelo
|
|
232
|
+
// id para a bolha receber os bytes DO TIPO dela — devolvendo um PNG para todo id, vídeo e áudio
|
|
233
|
+
// apareciam quebrados na thread mesmo havendo amostra válida do formato.
|
|
234
|
+
getMediaProxyUrl(mediaId): Promise<{ mimeType: string; data: string }> {
|
|
235
|
+
return withLatency(() => {
|
|
236
|
+
const found = Object.values(PREVIEW_DOCUMENTS)
|
|
237
|
+
.flat()
|
|
238
|
+
.find((document) => document.id === `preview/inbound/${mediaId}`)
|
|
239
|
+
return previewFileBase64(found?.mimeType, found?.filename)
|
|
240
|
+
})
|
|
187
241
|
},
|
|
188
242
|
|
|
189
243
|
takeover(conversationId): Promise<void> {
|
package/src/preview/index.ts
CHANGED
|
@@ -38,3 +38,8 @@ export type { PreviewWebhookClient, CreatePreviewWebhookClientParams } from './c
|
|
|
38
38
|
|
|
39
39
|
export { startPreviewScript, DEFAULT_PREVIEW_SCRIPT } from './startPreviewScript'
|
|
40
40
|
export type { PreviewScriptStep, StartPreviewScriptParams } from './startPreviewScript'
|
|
41
|
+
export { PREVIEW_FILE_SAMPLES, resolvePreviewFileSample } from './previewFileSamples'
|
|
42
|
+
export { createPreviewMediaResolver, previewFileUrl } from './previewMediaSource'
|
|
43
|
+
export { previewFileBase64 } from './previewMediaSource'
|
|
44
|
+
export { MediaTypesPreview, MEDIA_TYPES_CONVERSATION_ID } from './MediaTypesPreview'
|
|
45
|
+
export type { MediaTypesPreviewProps } from './MediaTypesPreview'
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Guarda a busca da biblioteca no mock — que é a implementação de referência da UI do SDK e tem de
|
|
3
|
+
* casar com o `companyDocumentSearch` do backend, senão o preview ensina um comportamento que a
|
|
4
|
+
* tela real não entrega.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { describe, expect, it } from 'bun:test'
|
|
8
|
+
|
|
9
|
+
import { createMockConversationsApi } from './createMockConversationsApi'
|
|
10
|
+
import { createPreviewStore } from './previewStore'
|
|
11
|
+
import { PREVIEW_CONVERSATIONS, PREVIEW_MESSAGES } from './previewFixtures'
|
|
12
|
+
|
|
13
|
+
function buildApi() {
|
|
14
|
+
const store = createPreviewStore({ conversations: PREVIEW_CONVERSATIONS, messages: PREVIEW_MESSAGES })
|
|
15
|
+
return createMockConversationsApi({ store })
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
async function search(term: string): Promise<{ total: number; conversations: string[] }> {
|
|
19
|
+
const page = await buildApi().getAllDocuments!({ search: term })
|
|
20
|
+
return { total: page.total, conversations: [...new Set(page.documents.map((document) => document.conversationId))] }
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
describe('busca da biblioteca no mock', () => {
|
|
24
|
+
it('acha por nome do arquivo', async () => {
|
|
25
|
+
const result = await search('contrato')
|
|
26
|
+
|
|
27
|
+
expect(result.total).toBe(1)
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('acha por telefone da conversa', async () => {
|
|
31
|
+
const porNumero = await search('94444')
|
|
32
|
+
|
|
33
|
+
expect(porNumero.conversations).toEqual(['5511944443333'])
|
|
34
|
+
expect(porNumero.total).toBeGreaterThan(0)
|
|
35
|
+
// Discrimina de verdade: 98888 é conversa que existe nas fixtures, mas sem arquivo — se o
|
|
36
|
+
// predicado do telefone não estivesse valendo, esta busca devolveria a biblioteca inteira.
|
|
37
|
+
expect((await search('98888')).total).toBe(0)
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
// O caso real: o atendente copia o número como a tela o mostra.
|
|
41
|
+
it('acha com o telefone formatado, como aparece na tela', async () => {
|
|
42
|
+
const cru = await search('5511944443333')
|
|
43
|
+
const formatado = await search('+55 (11) 94444-3333')
|
|
44
|
+
|
|
45
|
+
expect(formatado.total).toBe(cru.total)
|
|
46
|
+
expect(formatado.total).toBeGreaterThan(0)
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('não devolve nada para número de outra conversa', async () => {
|
|
50
|
+
expect((await search('99999999999')).total).toBe(0)
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
// Sem dígito, o predicado do telefone não existe — senão qualquer palavra casaria toda conversa.
|
|
54
|
+
it('termo sem dígito filtra só por nome', async () => {
|
|
55
|
+
expect((await search('zzz-inexistente')).total).toBe(0)
|
|
56
|
+
})
|
|
57
|
+
})
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Guarda que o preview entrega arquivo do tipo que promete.
|
|
3
|
+
*
|
|
4
|
+
* O defeito que motivou o arquivo: `getDocumentUrl` devolvia sempre a mesma imagem PNG, então
|
|
5
|
+
* "visualizar" num PDF abria um PNG rotulado `application/pdf` e o leitor dizia que o arquivo era
|
|
6
|
+
* inválido. Aqui os bytes são decodificados e conferidos pela assinatura de cada formato — teste que
|
|
7
|
+
* só olhasse o prefixo da data URL passaria com o bug de volta.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { describe, expect, it } from 'bun:test'
|
|
11
|
+
|
|
12
|
+
import { PREVIEW_FILE_SAMPLES, resolvePreviewFileSample } from './previewFileSamples'
|
|
13
|
+
import { PREVIEW_DOCUMENTS } from './previewFixtures'
|
|
14
|
+
|
|
15
|
+
function decode(dataUrl: string): { mimeType: string; bytes: Uint8Array } {
|
|
16
|
+
const [head, payload] = dataUrl.split(',')
|
|
17
|
+
const mimeType = head!.replace(/^data:/, '').replace(/;base64$/, '')
|
|
18
|
+
if (!head!.endsWith(';base64')) {
|
|
19
|
+
return { mimeType, bytes: new TextEncoder().encode(decodeURIComponent(payload!)) }
|
|
20
|
+
}
|
|
21
|
+
return { mimeType, bytes: Uint8Array.from(atob(payload!), (char) => char.charCodeAt(0)) }
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function startsWith(bytes: Uint8Array, signature: readonly number[], offset = 0): boolean {
|
|
25
|
+
return signature.every((byte, index) => bytes[offset + index] === byte)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const ascii = (text: string): number[] => [...text].map((char) => char.charCodeAt(0))
|
|
29
|
+
|
|
30
|
+
// Assinatura real de cada formato, não o rótulo do mimeType.
|
|
31
|
+
const SIGNATURES: Record<string, (bytes: Uint8Array) => boolean> = {
|
|
32
|
+
'application/pdf': (bytes) => startsWith(bytes, ascii('%PDF-')),
|
|
33
|
+
'image/png': (bytes) => startsWith(bytes, [0x89, 0x50, 0x4e, 0x47]),
|
|
34
|
+
'image/jpeg': (bytes) => startsWith(bytes, [0xff, 0xd8, 0xff]),
|
|
35
|
+
'image/webp': (bytes) => startsWith(bytes, ascii('RIFF')) && startsWith(bytes, ascii('WEBP'), 8),
|
|
36
|
+
'video/mp4': (bytes) => startsWith(bytes, ascii('ftyp'), 4),
|
|
37
|
+
'audio/mp4': (bytes) => startsWith(bytes, ascii('ftyp'), 4),
|
|
38
|
+
// ID3 quando há tag, ou o sync do primeiro frame MPEG.
|
|
39
|
+
'audio/mpeg': (bytes) => startsWith(bytes, ascii('ID3')) || (bytes[0] === 0xff && (bytes[1]! & 0xe0) === 0xe0),
|
|
40
|
+
'audio/ogg': (bytes) => startsWith(bytes, ascii('OggS')),
|
|
41
|
+
'audio/aac': (bytes) => bytes[0] === 0xff && (bytes[1]! & 0xf0) === 0xf0,
|
|
42
|
+
'text/plain': (bytes) => bytes.length > 0,
|
|
43
|
+
'text/csv': (bytes) => bytes.length > 0,
|
|
44
|
+
// Todo pacote Office é zip: assinatura PK\x03\x04.
|
|
45
|
+
'application/zip': (bytes) => startsWith(bytes, [0x50, 0x4b, 0x03, 0x04]),
|
|
46
|
+
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': (bytes) =>
|
|
47
|
+
startsWith(bytes, [0x50, 0x4b, 0x03, 0x04]),
|
|
48
|
+
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': (bytes) =>
|
|
49
|
+
startsWith(bytes, [0x50, 0x4b, 0x03, 0x04]),
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
describe('amostras de arquivo do preview', () => {
|
|
53
|
+
it('toda amostra declara o mimeType que anuncia', () => {
|
|
54
|
+
for (const [mimeType, dataUrl] of Object.entries(PREVIEW_FILE_SAMPLES)) {
|
|
55
|
+
expect(decode(dataUrl).mimeType).toBe(mimeType)
|
|
56
|
+
}
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
for (const [mimeType, isValid] of Object.entries(SIGNATURES)) {
|
|
60
|
+
it(`os bytes de ${mimeType} são realmente desse formato`, () => {
|
|
61
|
+
const sample = PREVIEW_FILE_SAMPLES[mimeType]
|
|
62
|
+
expect(sample).toBeDefined()
|
|
63
|
+
expect(isValid(decode(sample!).bytes)).toBe(true)
|
|
64
|
+
})
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// O PDF sem xref/startxref é o caso que alguns leitores recusam de cara.
|
|
68
|
+
it('o PDF tem tabela xref e termina em %%EOF', () => {
|
|
69
|
+
const text = new TextDecoder().decode(decode(PREVIEW_FILE_SAMPLES['application/pdf']!).bytes)
|
|
70
|
+
|
|
71
|
+
expect(text).toContain('\nxref\n')
|
|
72
|
+
expect(text).toContain('startxref')
|
|
73
|
+
expect(text.trimEnd().endsWith('%%EOF')).toBe(true)
|
|
74
|
+
})
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
describe('resolvePreviewFileSample', () => {
|
|
78
|
+
it('descarta parâmetro do mimeType — áudio de WhatsApp chega com codecs', () => {
|
|
79
|
+
expect(resolvePreviewFileSample('audio/ogg; codecs=opus')).toBe(PREVIEW_FILE_SAMPLES['audio/ogg'])
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
// Tipos aceitos pela Meta sem amostra local (sem encoder amr/3gp nesta máquina): tocam pela
|
|
83
|
+
// família, em vez de devolver bytes de outro formato.
|
|
84
|
+
it('cai na família quando não há amostra do tipo exato', () => {
|
|
85
|
+
expect(resolvePreviewFileSample('audio/amr')).toBe(PREVIEW_FILE_SAMPLES['audio/mpeg'])
|
|
86
|
+
expect(resolvePreviewFileSample('video/3gp')).toBe(PREVIEW_FILE_SAMPLES['video/mp4'])
|
|
87
|
+
expect(resolvePreviewFileSample('image/heic')).toBe(PREVIEW_FILE_SAMPLES['image/png'])
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
// Office legado é OLE2, não zip: servir o docx aqui daria arquivo corrompido no Word. Melhor um
|
|
91
|
+
// texto que abre e se explica.
|
|
92
|
+
it('explica em texto quando não há como forjar o binário', () => {
|
|
93
|
+
const resolved = resolvePreviewFileSample('application/msword', 'procuracao.doc')
|
|
94
|
+
|
|
95
|
+
expect(resolved.startsWith('data:text/plain')).toBe(true)
|
|
96
|
+
expect(decodeURIComponent(resolved)).toContain('procuracao.doc')
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
it('não devolve vazio para tipo desconhecido', () => {
|
|
100
|
+
expect(resolvePreviewFileSample(undefined).length).toBeGreaterThan(0)
|
|
101
|
+
expect(resolvePreviewFileSample('application/octet-stream').length).toBeGreaterThan(0)
|
|
102
|
+
})
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
describe('biblioteca do preview', () => {
|
|
106
|
+
// A infidelidade que existia: a biblioteca listava só `document`, enquanto o backend linka as
|
|
107
|
+
// cinco espécies de mídia. Foto, vídeo, áudio e sticker apareciam na tela real e não no preview.
|
|
108
|
+
it('lista foto, vídeo, áudio e sticker junto dos documentos', () => {
|
|
109
|
+
const mimeTypes = (PREVIEW_DOCUMENTS['5511944443333'] ?? []).map((document) => document.mimeType)
|
|
110
|
+
|
|
111
|
+
expect(mimeTypes.some((mimeType) => mimeType.startsWith('image/'))).toBe(true)
|
|
112
|
+
expect(mimeTypes.some((mimeType) => mimeType.startsWith('video/'))).toBe(true)
|
|
113
|
+
expect(mimeTypes.some((mimeType) => mimeType.startsWith('audio/'))).toBe(true)
|
|
114
|
+
expect(mimeTypes).toContain('image/webp')
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
// Cada tipo que a Cloud API da Meta aceita para envio de mídia.
|
|
118
|
+
const ACCEPTED_BY_META = [
|
|
119
|
+
'image/jpeg',
|
|
120
|
+
'image/png',
|
|
121
|
+
'image/webp',
|
|
122
|
+
'video/mp4',
|
|
123
|
+
'video/3gp',
|
|
124
|
+
'audio/aac',
|
|
125
|
+
'audio/amr',
|
|
126
|
+
'audio/mpeg',
|
|
127
|
+
'audio/mp4',
|
|
128
|
+
'audio/ogg',
|
|
129
|
+
'application/pdf',
|
|
130
|
+
'application/msword',
|
|
131
|
+
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
|
132
|
+
'application/vnd.ms-excel',
|
|
133
|
+
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
|
134
|
+
'application/vnd.ms-powerpoint',
|
|
135
|
+
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
|
136
|
+
'text/plain',
|
|
137
|
+
]
|
|
138
|
+
|
|
139
|
+
it('cobre todo tipo aceito pela Meta', () => {
|
|
140
|
+
const present = new Set((PREVIEW_DOCUMENTS['5511944443333'] ?? []).map((document) => document.mimeType))
|
|
141
|
+
const faltando = ACCEPTED_BY_META.filter((mimeType) => !present.has(mimeType))
|
|
142
|
+
|
|
143
|
+
expect(faltando).toEqual([])
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
it('todo item da biblioteca abre com bytes não vazios', () => {
|
|
147
|
+
for (const document of PREVIEW_DOCUMENTS['5511944443333'] ?? []) {
|
|
148
|
+
expect(decode(resolvePreviewFileSample(document.mimeType, document.filename)).bytes.length).toBeGreaterThan(0)
|
|
149
|
+
}
|
|
150
|
+
})
|
|
151
|
+
})
|