@adatechnology/conversations-ui 0.1.0-rc.4 → 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.
Files changed (42) hide show
  1. package/dist/chunk-LITPZCWW.js +1465 -0
  2. package/dist/index.d.ts +223 -29
  3. package/dist/index.js +316 -366
  4. package/dist/preview/index.d.ts +92 -3
  5. package/dist/preview/index.js +627 -103
  6. package/dist/types-CeixG2Z9.d.ts +324 -0
  7. package/package.json +2 -2
  8. package/src/Avatar.tsx +13 -2
  9. package/src/ConversationDocumentsPanel.tsx +342 -24
  10. package/src/ConversationListItem.tsx +18 -2
  11. package/src/DocumentsLibrary.tsx +322 -0
  12. package/src/FileIcon.test.ts +83 -0
  13. package/src/FileIcon.tsx +88 -11
  14. package/src/Lightbox.tsx +18 -3
  15. package/src/MediaRenderer.tsx +5 -2
  16. package/src/MessageBubble.tsx +18 -2
  17. package/src/MessageComposer.tsx +20 -3
  18. package/src/WhatsAppMessageEditor.tsx +28 -4
  19. package/src/hooks/useConversationActions.ts +56 -0
  20. package/src/hooks/useConversationDocuments.ts +11 -7
  21. package/src/hooks/useConversationList.ts +15 -9
  22. package/src/hooks/useConversationMessages.ts +2 -2
  23. package/src/index.ts +32 -11
  24. package/src/lib/cn.test.ts +29 -0
  25. package/src/lib/createMediaUrlResolver.ts +33 -0
  26. package/src/lib/paginated.test.ts +33 -0
  27. package/src/lib/paginated.ts +26 -0
  28. package/src/preview/MediaTypesPreview.tsx +87 -0
  29. package/src/preview/createMockConversationsApi.ts +175 -15
  30. package/src/preview/index.ts +6 -1
  31. package/src/preview/mockDocumentsSearch.test.ts +57 -0
  32. package/src/preview/preview.test.ts +5 -3
  33. package/src/preview/previewFileSamples.test.ts +151 -0
  34. package/src/preview/previewFileSamples.ts +74 -0
  35. package/src/preview/previewFixtures.ts +288 -1
  36. package/src/preview/previewMediaSource.test.ts +62 -0
  37. package/src/preview/previewMediaSource.ts +91 -0
  38. package/src/providers/types.ts +127 -9
  39. package/src/settings/WhatsAppCreateTemplateForm.tsx +3 -1
  40. package/src/useWaitingNotifications.ts +74 -29
  41. package/dist/chunk-4R6Y43DQ.js +0 -726
  42. package/dist/types-C0PtaO7S.d.ts +0 -207
@@ -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
@@ -42,7 +55,11 @@ export const MessageComposer = ({
42
55
  acceptedFileTypes = DEFAULT_ACCEPTED_FILE_TYPES,
43
56
  className,
44
57
  classNames,
58
+ labels,
45
59
  }: MessageComposerProps) => {
60
+ const emojiLabel = labels?.emoji ?? DEFAULT_MESSAGE_COMPOSER_LABELS.emoji
61
+ const attachLabel = labels?.attach ?? DEFAULT_MESSAGE_COMPOSER_LABELS.attach
62
+ const sendLabel = labels?.send ?? DEFAULT_MESSAGE_COMPOSER_LABELS.send
46
63
  const [internalText, setInternalText] = useState('')
47
64
  const [showEmoji, setShowEmoji] = useState(false)
48
65
  const [attachments, setAttachments] = useState<FilePreview[]>([])
@@ -168,7 +185,7 @@ export const MessageComposer = ({
168
185
  <div className={cn('flex items-end gap-1.5 rounded-xl bg-white px-3 py-2', classNames?.field)}>
169
186
  {showEmojiButton && (
170
187
  <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="Emoji">
188
+ <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
189
  <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
190
  </button>
174
191
  {showEmoji && (
@@ -194,7 +211,7 @@ export const MessageComposer = ({
194
211
  {showAttachButton && (
195
212
  <>
196
213
  <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="Anexar">
214
+ <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
215
  <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
216
  </button>
200
217
  </>
@@ -208,7 +225,7 @@ export const MessageComposer = ({
208
225
  ? 'bg-[#00a884] text-white hover:bg-[#06cf9c] shadow-sm'
209
226
  : 'bg-gray-200 text-gray-400 cursor-not-allowed'
210
227
  }`}
211
- aria-label="Enviar"
228
+ aria-label={sendLabel}
212
229
  >
213
230
  <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
231
  </button>
@@ -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="Negrito (*texto*)" aria-label="Negrito">
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="Itálico (_texto_)" aria-label="Itálico">
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="Tachado (~texto~)" aria-label="Tachado">
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={`Inserir ${token}`}
138
+ title={editorLabels.insertPlaceholder(token)}
115
139
  >
116
140
  {token}
117
141
  </button>
@@ -0,0 +1,56 @@
1
+ import { useMemo } from 'react'
2
+ import { useConversations } from '../providers/ConversationsProvider'
3
+ import type { ConversationTemplate } from '../providers/types'
4
+
5
+ export interface UseConversationActionsResult {
6
+ /** `undefined` quando a API do host não implementa a operação — a UI esconde a afordância. */
7
+ takeover: (() => Promise<void>) | undefined
8
+ release: (() => Promise<void>) | undefined
9
+ finalize: (() => Promise<void>) | undefined
10
+ }
11
+
12
+ /**
13
+ * Ações de atendimento de UMA conversa, já ligadas ao id.
14
+ *
15
+ * Separado de `useConversationMessages` porque assumir e devolver conversa também acontece a
16
+ * partir da lista, onde nenhuma thread está aberta — embutir nas mensagens obrigaria a carregar
17
+ * a thread inteira só para desenhar um botão na linha.
18
+ */
19
+ export function useConversationActions(conversationId: string): UseConversationActionsResult {
20
+ const context = useConversations()
21
+ if (!context) {
22
+ throw new Error('useConversationActions requires an ancestor <ConversationsProvider>')
23
+ }
24
+ const { api } = context
25
+
26
+ return useMemo(
27
+ () => ({
28
+ takeover: api.takeover ? () => api.takeover!(conversationId) : undefined,
29
+ release: api.release ? () => api.release!(conversationId) : undefined,
30
+ finalize: api.finalize ? () => api.finalize!(conversationId) : undefined,
31
+ }),
32
+ [api, conversationId],
33
+ )
34
+ }
35
+
36
+ export interface UseInboxActionsResult {
37
+ markAllRead: (() => Promise<void>) | undefined
38
+ listTemplates: (() => Promise<ConversationTemplate[]>) | undefined
39
+ }
40
+
41
+ /** Ações que valem para a caixa inteira, sem conversa selecionada. */
42
+ export function useInboxActions(): UseInboxActionsResult {
43
+ const context = useConversations()
44
+ if (!context) {
45
+ throw new Error('useInboxActions requires an ancestor <ConversationsProvider>')
46
+ }
47
+ const { api } = context
48
+
49
+ return useMemo(
50
+ () => ({
51
+ markAllRead: api.markAllRead ? () => api.markAllRead!() : undefined,
52
+ listTemplates: api.listTemplates ? () => api.listTemplates!() : undefined,
53
+ }),
54
+ [api],
55
+ )
56
+ }
@@ -1,14 +1,14 @@
1
1
  import { useConversations } from '../providers/ConversationsProvider'
2
2
  import { useAsyncResource } from './useAsyncResource'
3
- import type { ConversationDocument } from '../providers/types'
3
+ import { documentsOf, totalOf } from '../lib/paginated'
4
+ import type { ConversationDocument, ListDocumentsParams } from '../providers/types'
4
5
 
5
- export interface UseConversationDocumentsParams {
6
- search?: string
7
- page?: number
8
- }
6
+ export type UseConversationDocumentsParams = ListDocumentsParams
9
7
 
10
8
  export interface UseConversationDocumentsResult {
11
9
  documents: ConversationDocument[]
10
+ /** Total no servidor. Cai para o tamanho da página quando a API devolve só o array. */
11
+ total: number
12
12
  loading: boolean
13
13
  error: Error | undefined
14
14
  refetch: () => Promise<void>
@@ -28,8 +28,12 @@ export function useConversationDocuments(
28
28
 
29
29
  const { data, loading, error, refetch } = useAsyncResource(
30
30
  () => (conversationId ? api.getDocuments(conversationId, params) : Promise.resolve([])),
31
- [conversationId, params?.search, params?.page],
31
+ [conversationId, params?.search, params?.page, params?.limit, params?.source, params?.sortDirection],
32
32
  )
33
33
 
34
- return { documents: data ?? [], loading, error, refetch }
34
+ if (data === undefined) {
35
+ return { documents: [], total: 0, loading, error, refetch }
36
+ }
37
+
38
+ return { documents: documentsOf(data), total: totalOf(data), loading, error, refetch }
35
39
  }
@@ -1,16 +1,14 @@
1
1
  import { useConversations } from '../providers/ConversationsProvider'
2
2
  import { useAsyncResource } from './useAsyncResource'
3
- import type { ConversationSummary } from '../providers/types'
3
+ import { conversationsOf, totalOf } from '../lib/paginated'
4
+ import type { ConversationSummary, ListConversationsParams } from '../providers/types'
4
5
 
5
- export interface UseConversationListParams {
6
- page?: number
7
- limit?: number
8
- waitingHuman?: boolean
9
- search?: string
10
- }
6
+ export type UseConversationListParams = ListConversationsParams
11
7
 
12
8
  export interface UseConversationListResult {
13
9
  conversations: ConversationSummary[]
10
+ /** Total no servidor. Cai para o tamanho da página quando a API devolve só o array. */
11
+ total: number
14
12
  loading: boolean
15
13
  error: Error | undefined
16
14
  refetch: () => Promise<void>
@@ -23,10 +21,18 @@ export function useConversationList(params?: UseConversationListParams): UseConv
23
21
  }
24
22
  const { api } = context
25
23
 
24
+ // `filters` é objeto novo a cada render do host; serializar evita refetch em laço sem obrigar
25
+ // o consumidor a memoizar — omissão que só apareceria como loop de rede em produção.
26
+ const filtersKey = JSON.stringify(params?.filters ?? {})
27
+
26
28
  const { data, loading, error, refetch } = useAsyncResource(
27
29
  () => api.fetchConversations(params),
28
- [params?.page, params?.limit, params?.waitingHuman, params?.search],
30
+ [params?.page, params?.limit, params?.waitingHuman, params?.search, filtersKey],
29
31
  )
30
32
 
31
- return { conversations: data ?? [], loading, error, refetch }
33
+ if (data === undefined) {
34
+ return { conversations: [], total: 0, loading, error, refetch }
35
+ }
36
+
37
+ return { conversations: conversationsOf(data), total: totalOf(data), loading, error, refetch }
32
38
  }
@@ -10,7 +10,7 @@ export interface UseConversationMessagesResult {
10
10
  refetch: () => Promise<void>
11
11
  sendMessage: (text: string) => Promise<MessagePayload>
12
12
  sendMedia: (data: { base64: string; mimeType: string; filename: string; caption?: string }) => Promise<MessagePayload>
13
- sendTemplate: (data: { templateName: string; languageCode?: string; bodyParams?: string[] }) => Promise<void>
13
+ sendTemplate: (data: { templateName?: string; languageCode?: string; bodyParams?: string[] }) => Promise<void>
14
14
  markRead: () => Promise<void>
15
15
  }
16
16
 
@@ -51,7 +51,7 @@ export function useConversationMessages(
51
51
  )
52
52
 
53
53
  const sendTemplate = useCallback(
54
- async (templateData: { templateName: string; languageCode?: string; bodyParams?: string[] }) => {
54
+ async (templateData: { templateName?: string; languageCode?: string; bodyParams?: string[] }) => {
55
55
  await api.sendTemplate(conversationId, templateData)
56
56
  await refetch()
57
57
  },
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'
@@ -74,6 +78,11 @@ export type { BuildTranscriptTextParams } from './conversationTranscript'
74
78
  export { useDarkMode, useIsDarkTheme } from './useDarkMode'
75
79
  export { useIsNarrow, NARROW_MAX_WIDTH_PX } from './useIsNarrow'
76
80
  export { useWaitingNotifications } from './useWaitingNotifications'
81
+ export type {
82
+ UseWaitingNotificationsLabels,
83
+ UseWaitingNotificationsParams,
84
+ UseWaitingNotificationsResult,
85
+ } from './useWaitingNotifications'
77
86
 
78
87
  export { ConversationsProvider, useConversations } from './providers/ConversationsProvider'
79
88
 
@@ -96,10 +105,13 @@ export { useConversationList } from './hooks/useConversationList'
96
105
  export { useConversationContext } from './hooks/useConversationContext'
97
106
  export { useConversationDocuments } from './hooks/useConversationDocuments'
98
107
  export { useConversationRealtime, useGlobalRealtime } from './hooks/useConversationRealtime'
108
+ export { useConversationActions, useInboxActions } from './hooks/useConversationActions'
99
109
 
100
110
  export { parseWhatsAppFormatting, waToHTML, htmlToWA, waToHTMLInline } from './lib/whatsapp-formatting'
101
111
  export { formatPhone, phoneInitials } from './lib/phone'
102
- export { formatTimestamp, formatFileSize } from './lib/format'
112
+ // `formatDateTime` e `isSameDay` eram usados pelas bolhas e pelo divisor de data; exportá-los
113
+ // evita que cada host mantenha a própria cópia e acabe com timeline e transcript divergindo.
114
+ export { formatTimestamp, formatFileSize, formatDateTime, isSameDay } from './lib/format'
103
115
 
104
116
  export type { MessagePayload, ConversationsUIConfig, ConversationsTheme, ConversationsFeatures } from './types'
105
117
  export type {
@@ -108,21 +120,29 @@ export type {
108
120
  ConversationEventSource,
109
121
  ConversationSummary,
110
122
  ConversationDocument,
123
+ ConversationPage,
124
+ ConversationDocumentPage,
125
+ CompanyDocument,
126
+ CompanyDocumentPage,
127
+ ConversationTemplate,
128
+ ListConversationsParams,
129
+ ListDocumentsParams,
111
130
  } from './providers/types'
131
+ export type { UseConversationActionsResult, UseInboxActionsResult } from './hooks/useConversationActions'
112
132
 
113
133
  export type { MessageBubbleProps } from './MessageBubble'
114
134
  export type { ConversationWallpaperProps } from './Wallpaper'
115
135
  export type { ConversationLocales, ConversationLocalesProviderProps } from './ConversationLocalesProvider'
116
136
  export type { AudioPlayerProps } from './AudioPlayer'
117
137
  export type { EmojiPickerProps } from './EmojiPicker'
118
- export type { MessageComposerProps, MessageComposerClassNames } from './MessageComposer'
119
- export type { WhatsAppMessageEditorProps } from './WhatsAppMessageEditor'
138
+ export type { MessageComposerProps, MessageComposerClassNames, MessageComposerLabels } from './MessageComposer'
139
+ export type { WhatsAppMessageEditorProps, WhatsAppMessageEditorLabels } from './WhatsAppMessageEditor'
120
140
  export type { SimpleEmojiPickerProps } from './SimpleEmojiPicker'
121
141
  export type { DateDividerProps, DateDividerClassNames } from './DateDivider'
122
- export type { AvatarProps } from './Avatar'
123
- export type { ConversationListItemProps } from './ConversationListItem'
142
+ export type { AvatarProps, AvatarLabels } from './Avatar'
143
+ export type { ConversationListItemProps, ConversationListItemLabels } from './ConversationListItem'
124
144
  export type { StatusTicksProps } from './StatusTicks'
125
- export type { LightboxProps } from './Lightbox'
145
+ export type { LightboxProps, LightboxLabels } from './Lightbox'
126
146
  export type { MediaRendererProps, ResolveMediaUrl } from './MediaRenderer'
127
147
  export type { FileIconProps } from './FileIcon'
128
148
  export type { MessageTextProps } from './MessageText'
@@ -154,3 +174,4 @@ export type { UseConversationContextResult } from './hooks/useConversationContex
154
174
  export type { UseConversationDocumentsParams, UseConversationDocumentsResult } from './hooks/useConversationDocuments'
155
175
  export type { ConversationRealtimeHandler } from './hooks/useConversationRealtime'
156
176
  export type { AsyncResourceState } from './hooks/useAsyncResource'
177
+ export { createMediaUrlResolver } from './lib/createMediaUrlResolver'
@@ -0,0 +1,29 @@
1
+ import { describe, expect, it } from 'bun:test'
2
+
3
+ import { cn } from './cn'
4
+
5
+ describe('cn', () => {
6
+ // O contrato de `classNames`/`className` dos componentes depende disto: se o override não vencer,
7
+ // a API de customização é decorativa. Concatenar falharia justo no caso comum — o Tailwind emite
8
+ // `px-2` antes de `px-4`, então a base ganharia de quem quer apertar o espaçamento.
9
+ it('o override do host vence a classe base em conflito', () => {
10
+ expect(cn('px-4 py-3', 'px-2')).toBe('py-3 px-2')
11
+ })
12
+
13
+ it('mantém o que não conflita', () => {
14
+ expect(cn('flex items-center gap-3', 'gap-1')).toBe('flex items-center gap-1')
15
+ })
16
+
17
+ it('preserva classes próprias do pacote, que o merge não conhece', () => {
18
+ expect(cn('cv-row flex', 'bg-red-50')).toBe('cv-row flex bg-red-50')
19
+ })
20
+
21
+ it('ignora ausente, falso e vazio', () => {
22
+ expect(cn('border-b', undefined, false, '')).toBe('border-b')
23
+ })
24
+
25
+ it('aplica condicional na ordem recebida', () => {
26
+ const isCompact = false
27
+ expect(cn('mt-2', isCompact && 'mt-8', 'mt-4')).toBe('mt-4')
28
+ })
29
+ })
@@ -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,33 @@
1
+ import { describe, expect, it } from 'bun:test'
2
+ import { conversationsOf, documentsOf, totalOf } from './paginated'
3
+ import type { ConversationDocument, ConversationSummary } from '../providers/types'
4
+
5
+ const conversation = { id: '1', whatsappNumber: '5511900000000' } as ConversationSummary
6
+ const document = { id: 'd1', filename: 'contrato.pdf' } as ConversationDocument
7
+
8
+ describe('normalização de lista ou página', () => {
9
+ // As duas formas existem porque o contrato aceita as duas: array puro é o retorno original, e
10
+ // implementações antigas continuam válidas. Um consumidor que só tratasse uma delas quebraria
11
+ // em runtime, não no compilador.
12
+ it('aceita o array puro do contrato original', () => {
13
+ expect(conversationsOf([conversation])).toEqual([conversation])
14
+ expect(documentsOf([document])).toEqual([document])
15
+ })
16
+
17
+ it('desembrulha a forma paginada', () => {
18
+ expect(conversationsOf({ conversations: [conversation], total: 42 })).toEqual([conversation])
19
+ expect(documentsOf({ documents: [document], total: 7 })).toEqual([document])
20
+ })
21
+
22
+ it('usa o total do servidor quando ele vem, e o tamanho da página quando não vem', () => {
23
+ // A distinção importa: com array puro não dá para saber se a página é a última, então o
24
+ // melhor palpite honesto é o que se tem em mãos.
25
+ expect(totalOf({ conversations: [conversation], total: 42 })).toBe(42)
26
+ expect(totalOf([conversation])).toBe(1)
27
+ })
28
+
29
+ it('trata página vazia sem confundir com ausência de dados', () => {
30
+ expect(conversationsOf({ conversations: [], total: 0 })).toEqual([])
31
+ expect(totalOf({ conversations: [], total: 0 })).toBe(0)
32
+ })
33
+ })
@@ -0,0 +1,26 @@
1
+ import type {
2
+ ConversationDocument,
3
+ ConversationDocumentPage,
4
+ ConversationPage,
5
+ ConversationSummary,
6
+ } from '../providers/types'
7
+
8
+ /**
9
+ * `fetchConversations` e `getDocuments` devolvem o array puro (contrato original) ou uma página
10
+ * com total. Normalizar num lugar só evita que cada consumidor invente sua própria checagem —
11
+ * e um consumidor esquecido não falha no compilador, falha em runtime com `.map is not a
12
+ * function`.
13
+ */
14
+ export function conversationsOf(result: ConversationSummary[] | ConversationPage): ConversationSummary[] {
15
+ return Array.isArray(result) ? result : result.conversations
16
+ }
17
+
18
+ export function documentsOf(result: ConversationDocument[] | ConversationDocumentPage): ConversationDocument[] {
19
+ return Array.isArray(result) ? result : result.documents
20
+ }
21
+
22
+ export function totalOf(
23
+ result: ConversationSummary[] | ConversationPage | ConversationDocument[] | ConversationDocumentPage,
24
+ ): number {
25
+ return Array.isArray(result) ? result.length : result.total
26
+ }
@@ -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
+ }