@adatechnology/conversations-ui 0.1.0-rc.9 → 0.1.0

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 (129) hide show
  1. package/dist/ConversationSimulatorPanel--5fIzXWY.d.ts +804 -0
  2. package/dist/{chunk-G5BM3VBP.js → chunk-BJNRLLDO.js} +1248 -282
  3. package/dist/chunk-DKPXKQGC.js +110 -0
  4. package/dist/{chunk-2AYDBWNE.js → chunk-WCBDXZ3X.js} +13 -3
  5. package/dist/flows/index.d.ts +422 -5
  6. package/dist/flows/index.js +2502 -676
  7. package/dist/index.d.ts +919 -17
  8. package/dist/index.js +3676 -675
  9. package/dist/preview/index.d.ts +62 -105
  10. package/dist/preview/index.js +162 -284
  11. package/dist/styles.css +893 -0
  12. package/package.json +9 -8
  13. package/src/AudioPlayer.tsx +8 -0
  14. package/src/AudioRecorderButton.test.tsx +30 -0
  15. package/src/AudioRecorderButton.tsx +248 -0
  16. package/src/AudioTranscription.test.tsx +115 -0
  17. package/src/AudioTranscription.tsx +252 -0
  18. package/src/Avatar.tsx +1 -1
  19. package/src/ConversationContextPanel.tsx +218 -44
  20. package/src/ConversationDocumentsPanel.tsx +11 -6
  21. package/src/ConversationHeader.test.tsx +66 -0
  22. package/src/ConversationHeader.tsx +147 -47
  23. package/src/ConversationListItem.tsx +8 -6
  24. package/src/ConversationLocalesProvider.tsx +28 -0
  25. package/src/ConversationRow.tsx +53 -7
  26. package/src/DarkModeToggle.test.tsx +76 -0
  27. package/src/DarkModeToggle.tsx +92 -0
  28. package/src/DocumentsLibrary.tsx +67 -7
  29. package/src/EmojiPicker.tsx +2 -1
  30. package/src/InteractiveMessage.tsx +3 -0
  31. package/src/Lightbox.tsx +1 -1
  32. package/src/MediaRenderer.tsx +88 -15
  33. package/src/MessageBubble.test.tsx +41 -0
  34. package/src/MessageBubble.tsx +47 -5
  35. package/src/MessageComposer.test.tsx +35 -0
  36. package/src/MessageComposer.tsx +122 -17
  37. package/src/MessageText.tsx +2 -1
  38. package/src/MessageTimestamp.tsx +2 -1
  39. package/src/RichMessageComposer.test.tsx +113 -0
  40. package/src/RichMessageComposer.tsx +551 -0
  41. package/src/SimpleEmojiPicker.tsx +5 -3
  42. package/src/StatusTicks.tsx +1 -1
  43. package/src/Toast.tsx +4 -0
  44. package/src/Tooltip.test.ts +42 -0
  45. package/src/Tooltip.tsx +167 -0
  46. package/src/Wallpaper.tsx +27 -13
  47. package/src/WhatsAppMessageEditor.tsx +10 -7
  48. package/src/WindowExpiredNotice.tsx +12 -4
  49. package/src/{preview/audioRecorderFormat.test.ts → audioRecorderFormat.test.ts} +1 -1
  50. package/src/buildOutput.test.ts +79 -0
  51. package/src/composer.constant.ts +33 -0
  52. package/src/conversationTranscript.test.ts +57 -0
  53. package/src/conversationTranscript.ts +29 -4
  54. package/src/conversationWindow.ts +7 -5
  55. package/src/documentTypeLabel.test.ts +57 -0
  56. package/src/documents/DocumentsWorkspace.tsx +550 -0
  57. package/src/documents/index.ts +8 -0
  58. package/src/documents/labels.ts +92 -0
  59. package/src/flows/FlowConnectionEdge.tsx +104 -0
  60. package/src/flows/FlowGroupHeader.tsx +12 -2
  61. package/src/flows/FlowLegend.tsx +125 -0
  62. package/src/flows/FlowMapCanvas.tsx +15 -12
  63. package/src/flows/FlowMapNode.tsx +4 -1
  64. package/src/flows/FlowNodeCard.tsx +219 -34
  65. package/src/flows/FlowNodePanel.tsx +153 -39
  66. package/src/flows/FlowPalette.tsx +156 -70
  67. package/src/flows/FlowPortalNode.tsx +1 -1
  68. package/src/flows/FlowWhatsAppPreview.tsx +14 -3
  69. package/src/flows/FlowsWorkspace.tsx +1255 -0
  70. package/src/flows/flowCanvasModel.test.ts +456 -0
  71. package/src/flows/flowCanvasModel.ts +378 -0
  72. package/src/flows/flowEditorOps.test.ts +276 -0
  73. package/src/flows/flowEditorOps.ts +202 -0
  74. package/src/flows/flowGraph.ts +78 -53
  75. package/src/flows/flowMenuPlacement.test.ts +130 -0
  76. package/src/flows/flowMenuPlacement.ts +86 -0
  77. package/src/flows/index.ts +51 -2
  78. package/src/flows/labels.ts +180 -0
  79. package/src/flows/workspaceContract.test.ts +126 -0
  80. package/src/hooks/useContainerWidth.ts +35 -0
  81. package/src/hooks/useConversationRealtime.ts +10 -8
  82. package/src/hooks/useScrollToLatestMessage.ts +127 -0
  83. package/src/hooks/useUrlFilterState.ts +107 -0
  84. package/src/icon.constant.ts +12 -0
  85. package/src/index.ts +100 -0
  86. package/src/lib/composer-formatting.test.ts +78 -0
  87. package/src/lib/composer-formatting.ts +145 -0
  88. package/src/lib/whatsapp-formatting.test.tsx +37 -0
  89. package/src/lib/whatsapp-formatting.tsx +28 -3
  90. package/src/listing/index.tsx +202 -0
  91. package/src/pagination.constant.ts +10 -0
  92. package/src/preview/ConversationPreview.tsx +84 -45
  93. package/src/preview/ConversationSimulatorClient.ts +143 -0
  94. package/src/preview/ConversationSimulatorPanel.test.tsx +55 -0
  95. package/src/preview/ConversationSimulatorPanel.tsx +131 -0
  96. package/src/preview/createPreviewBridgeClient.test.ts +92 -0
  97. package/src/preview/createPreviewBridgeClient.ts +124 -0
  98. package/src/preview/createPreviewMediaUploader.ts +82 -0
  99. package/src/preview/createPreviewWebhookClient.test.ts +96 -0
  100. package/src/preview/createPreviewWebhookClient.ts +99 -3
  101. package/src/preview/index.ts +36 -2
  102. package/src/preview/previewMediaUploader.test.ts +61 -0
  103. package/src/providers/ConversationsProvider.tsx +8 -6
  104. package/src/providers/types.ts +59 -2
  105. package/src/quickReply.test.ts +58 -0
  106. package/src/replyLatency.test.ts +71 -0
  107. package/src/replyLatency.ts +57 -0
  108. package/src/settings/MessagesWorkspace.tsx +571 -0
  109. package/src/settings/TopicsForm.tsx +2 -0
  110. package/src/settings/TranscriptionSettingsForm.test.tsx +81 -0
  111. package/src/settings/TranscriptionSettingsForm.tsx +190 -0
  112. package/src/settings/WelcomeFarewellForm.tsx +1 -0
  113. package/src/settings/WhatsAppCreateTemplateForm.tsx +1 -0
  114. package/src/settings/WhatsAppTemplateSettingsForm.tsx +5 -2
  115. package/src/settings/WhatsAppTemplatesSettings.test.tsx +61 -0
  116. package/src/settings/WhatsAppTemplatesSettings.tsx +22 -2
  117. package/src/styles.css +858 -0
  118. package/src/theme.ts +13 -0
  119. package/src/types.ts +26 -0
  120. package/src/workspace/BulkTemplateModal.tsx +132 -0
  121. package/src/workspace/ConversationPane.tsx +432 -0
  122. package/src/workspace/ConversationsInboxList.tsx +194 -0
  123. package/src/workspace/ConversationsWorkspace.tsx +423 -0
  124. package/src/workspace/index.ts +17 -0
  125. package/src/workspace/labels.test.ts +17 -0
  126. package/src/workspace/labels.ts +85 -0
  127. package/src/workspace/useConversationsInbox.ts +332 -0
  128. package/dist/types-B5C1DLu1.d.ts +0 -365
  129. package/src/preview/AudioRecorderButton.tsx +0 -117
@@ -6,9 +6,14 @@
6
6
  * Assina com WebCrypto porque `node:crypto` não existe no navegador. Os builders vêm dos contratos
7
7
  * (isomórficos) justamente para que o mesmo payload seja montado nos dois runtimes.
8
8
  *
9
- * ⚠️ Isto carrega o app secret de DESENVOLVIMENTO no bundle. existe para o docker local, e
10
- * `assertPreviewEnvironment` recusa rodar em produção um segredo de dev vazado é irrelevante,
11
- * mas o hábito de embarcar segredo em frontend não é.
9
+ * ⚠️ SOMENTE EXECUÇÃO LOCAL. Isto carrega o app secret no bundle, e bundle é público onde quer que
10
+ * seja servido em qualquer ambiente com URL acessível (homologação inclusive) usar esta fábrica
11
+ * equivale a publicar o segredo, e quem o tiver forja webhooks válidos daquele app da Meta: injeta
12
+ * mensagem de qualquer número e dispara os fluxos. `assertPreviewEnvironment` barra produção, mas
13
+ * homologação passaria, então a barreira não basta.
14
+ *
15
+ * Para qualquer ambiente publicado use `createPreviewBridgeClient`: o navegador manda a intenção e
16
+ * o servidor assina com o segredo que ele já tem.
12
17
  */
13
18
 
14
19
  import {
@@ -20,6 +25,7 @@ import {
20
25
  type InboundMediaType,
21
26
  type InteractiveReplyOption,
22
27
  } from '@adatechnology/meta-whatsapp-contracts/testing'
28
+ import { createPreviewMediaUploader, type PreviewUploadedMedia } from './createPreviewMediaUploader'
23
29
 
24
30
  export type PreviewWebhookClient = {
25
31
  sendText(text: string): Promise<void>
@@ -27,6 +33,19 @@ export type PreviewWebhookClient = {
27
33
  sendListReply(reply: InteractiveReplyOption): Promise<void>
28
34
  sendAudio(mediaId: string): Promise<void>
29
35
  sendMedia(params: SendPreviewMediaParams): Promise<void>
36
+ /**
37
+ * Guarda um arquivo gravado e devolve o `mediaId` já prefixado, pronto para `sendMedia`.
38
+ *
39
+ * Existe no cliente, e não como prop de quem monta a tela, porque isto é exatamente o que ele já
40
+ * sabe fazer: falar com ESTE host usando ESTE segredo. Enquanto era responsabilidade do produto,
41
+ * o resultado prático foi um produto com microfone no simulador e outro sem — não por decisão,
42
+ * por esquecimento. Cliente montado, microfone na tela.
43
+ *
44
+ * Opcional porque o cliente-ponte só consegue oferecer isto quando sabe a rota de mídia (ou quando
45
+ * o host injeta a função): sem destino, gravar áudio seria falar para o vazio, e aí a tela
46
+ * corretamente não desenha o gravador.
47
+ */
48
+ uploadMedia?(file: File): Promise<PreviewUploadedMedia>
30
49
  }
31
50
 
32
51
  export type SendPreviewMediaParams = {
@@ -47,10 +66,25 @@ export type CreatePreviewWebhookClientParams = {
47
66
  readonly appSecret: string
48
67
  readonly from: string
49
68
  readonly phoneNumberId?: string
69
+ /**
70
+ * Rota que guarda o áudio gravado. Por padrão, `/v1/preview/media` na mesma origem do webhook.
71
+ *
72
+ * O padrão cobre o caso normal — as duas rotas são do mesmo servidor — e a prop existe para quem
73
+ * publica a API em outro host ou versiona o caminho.
74
+ */
75
+ readonly mediaUploadUrl?: string
50
76
  // Escape hatch para teste; em runtime real é sempre o fetch global.
51
77
  readonly fetchImplementation?: typeof fetch
52
78
  }
53
79
 
80
+ /** Falha da rota de upload, separada da do webhook: os dois lados quebram por motivos diferentes. */
81
+ export class PreviewMediaUploadRejectedError extends Error {
82
+ constructor(readonly status: number) {
83
+ super(`A rota de mídia do simulador recusou o upload (HTTP ${status}).`)
84
+ this.name = 'PreviewMediaUploadRejectedError'
85
+ }
86
+ }
87
+
54
88
  export class PreviewInProductionError extends Error {
55
89
  constructor() {
56
90
  super('O preview de conversa carrega um app secret e não pode ser montado em produção.')
@@ -97,6 +131,53 @@ export async function signPreviewPayload(params: { rawBody: string; appSecret: s
97
131
 
98
132
  const signWithWebCrypto = signPreviewPayload
99
133
 
134
+ export const DEFAULT_MEDIA_UPLOAD_PATH = '/v1/preview/media'
135
+
136
+ /**
137
+ * Mesma origem do webhook: as duas rotas são do mesmo servidor no caso normal.
138
+ *
139
+ * Quando `webhookUrl` é relativa — que é o que sai de um `VITE_API_URL` vazio, com o front servido
140
+ * pela própria API — não há origem para resolver contra, e o caminho relativo já aponta para o
141
+ * lugar certo. `new URL` com base relativa lançaria, e o microfone morreria no `createClient`.
142
+ */
143
+ export function defaultMediaUploadUrl(webhookUrl: string): string {
144
+ try {
145
+ return new URL(DEFAULT_MEDIA_UPLOAD_PATH, webhookUrl).toString()
146
+ } catch {
147
+ return DEFAULT_MEDIA_UPLOAD_PATH
148
+ }
149
+ }
150
+
151
+ /**
152
+ * O POST de mídia, sem a parte de assinatura — para os dois clientes usarem o mesmo caminho.
153
+ *
154
+ * O cliente-ponte autentica por sessão e o de webhook por HMAC; o que não muda é a rota, o formato
155
+ * do corpo e a leitura do `uploadId`. Duas cópias disso é como o prefixo de mídia divergiu antes.
156
+ */
157
+ export function createPreviewMediaPoster(params: {
158
+ readonly url: string
159
+ readonly headers?: (mimeType: string) => Promise<Readonly<Record<string, string>>>
160
+ readonly fetchImplementation?: typeof fetch
161
+ }): (file: File) => Promise<PreviewUploadedMedia> {
162
+ return createPreviewMediaUploader({
163
+ upload: async (request) => {
164
+ const performRequest = params.fetchImplementation ?? fetch
165
+ const extraHeaders = (await params.headers?.(request.mimeType)) ?? {}
166
+
167
+ const response = await performRequest(params.url, {
168
+ method: 'POST',
169
+ headers: { 'content-type': 'application/json', ...extraHeaders },
170
+ body: JSON.stringify(request),
171
+ })
172
+
173
+ if (!response.ok) throw new PreviewMediaUploadRejectedError(response.status)
174
+
175
+ const body = (await response.json()) as { data: { uploadId: string } }
176
+ return { uploadId: body.data.uploadId }
177
+ },
178
+ })
179
+ }
180
+
100
181
  export function createPreviewWebhookClient(params: CreatePreviewWebhookClientParams): PreviewWebhookClient {
101
182
  const sendPayload = async (payload: ReturnType<typeof buildInboundTextPayload>): Promise<void> => {
102
183
  // Serializa uma vez só: assinar um texto e enviar outro (mesmo com o conteúdo igual) derruba a
@@ -114,6 +195,20 @@ export function createPreviewWebhookClient(params: CreatePreviewWebhookClientPar
114
195
  if (!response.ok) throw new PreviewWebhookRejectedError(response.status)
115
196
  }
116
197
 
198
+ /**
199
+ * Assina o MIME, não o binário — mesmo contrato da rota.
200
+ *
201
+ * Passar megabytes de base64 pelo HMAC do navegador travaria a aba a cada nota de voz; o que a
202
+ * assinatura protege aqui é o acesso à rota, e o binário já tem teto de tamanho no servidor.
203
+ */
204
+ const uploadMedia = createPreviewMediaPoster({
205
+ url: params.mediaUploadUrl ?? defaultMediaUploadUrl(params.webhookUrl),
206
+ headers: async (mimeType) => ({
207
+ 'x-preview-signature': await signWithWebCrypto({ rawBody: mimeType, appSecret: params.appSecret }),
208
+ }),
209
+ ...(params.fetchImplementation ? { fetchImplementation: params.fetchImplementation } : {}),
210
+ })
211
+
117
212
  const envelope = { from: params.from, phoneNumberId: params.phoneNumberId }
118
213
 
119
214
  return {
@@ -122,5 +217,6 @@ export function createPreviewWebhookClient(params: CreatePreviewWebhookClientPar
122
217
  sendListReply: (reply) => sendPayload(buildInboundInteractivePayload({ ...envelope, listReply: reply })),
123
218
  sendAudio: (mediaId) => sendPayload(buildInboundAudioPayload({ ...envelope, mediaId })),
124
219
  sendMedia: (media) => sendPayload(buildInboundMediaPayload({ ...envelope, ...media })),
220
+ uploadMedia,
125
221
  }
126
222
  }
@@ -25,12 +25,33 @@ export type { CreateMockSSEProviderParams } from './createMockSSEProvider'
25
25
 
26
26
  export { PREVIEW_CONVERSATIONS, PREVIEW_MESSAGES, PREVIEW_DOCUMENTS } from './previewFixtures'
27
27
 
28
+ export {
29
+ ConversationSimulatorPanel,
30
+ DEFAULT_CONVERSATION_SIMULATOR_PANEL_LABELS,
31
+ simulatorPanelLabelsOf,
32
+ } from './ConversationSimulatorPanel'
33
+ export type { ConversationSimulatorPanelProps, ConversationSimulatorPanelLabels } from './ConversationSimulatorPanel'
34
+
35
+ export {
36
+ acceptsMediaKind,
37
+ mediaKindOf,
38
+ isConversationSimulatorClient,
39
+ toConversationSimulatorClient,
40
+ SIMULATOR_FILE_MEDIA_KINDS,
41
+ } from './ConversationSimulatorClient'
42
+ export type {
43
+ ConversationSimulatorClient,
44
+ SendSimulatorMediaParams,
45
+ SimulatorMediaKind,
46
+ ToSimulatorClientParams,
47
+ } from './ConversationSimulatorClient'
48
+
28
49
  export { ConversationPreview } from './ConversationPreview'
29
50
  export { mediaTypeOf } from './ConversationPreview'
30
51
  export type { ConversationPreviewProps, PreviewUploadedMedia } from './ConversationPreview'
31
52
 
32
- export { AudioRecorderButton, DEFAULT_AUDIO_RECORDER_BUTTON_LABELS } from './AudioRecorderButton'
33
- export type { AudioRecorderButtonProps, AudioRecorderButtonLabels } from './AudioRecorderButton'
53
+ export { AudioRecorderButton, DEFAULT_AUDIO_RECORDER_BUTTON_LABELS } from '../AudioRecorderButton'
54
+ export type { AudioRecorderButtonProps, AudioRecorderButtonLabels } from '../AudioRecorderButton'
34
55
 
35
56
  export {
36
57
  createPreviewWebhookClient,
@@ -38,6 +59,9 @@ export {
38
59
  assertPreviewEnvironment,
39
60
  PreviewInProductionError,
40
61
  PreviewWebhookRejectedError,
62
+ PreviewMediaUploadRejectedError,
63
+ createPreviewMediaPoster,
64
+ DEFAULT_MEDIA_UPLOAD_PATH,
41
65
  } from './createPreviewWebhookClient'
42
66
  export type {
43
67
  PreviewWebhookClient,
@@ -45,6 +69,13 @@ export type {
45
69
  SendPreviewMediaParams,
46
70
  } from './createPreviewWebhookClient'
47
71
 
72
+ export { createPreviewBridgeClient, PreviewBridgeRejectedError } from './createPreviewBridgeClient'
73
+ export type {
74
+ CreatePreviewBridgeClientParams,
75
+ PreviewInboundCommand,
76
+ SendPreviewInboundCommand,
77
+ } from './createPreviewBridgeClient'
78
+
48
79
  export { startPreviewScript, DEFAULT_PREVIEW_SCRIPT } from './startPreviewScript'
49
80
  export type { PreviewScriptStep, StartPreviewScriptParams } from './startPreviewScript'
50
81
  export { PREVIEW_FILE_SAMPLES, resolvePreviewFileSample } from './previewFileSamples'
@@ -52,3 +83,6 @@ export { createPreviewMediaResolver, previewFileUrl } from './previewMediaSource
52
83
  export { previewFileBase64 } from './previewMediaSource'
53
84
  export { MediaTypesPreview, MEDIA_TYPES_CONVERSATION_ID } from './MediaTypesPreview'
54
85
  export type { MediaTypesPreviewProps } from './MediaTypesPreview'
86
+
87
+ export { createPreviewMediaUploader, PREVIEW_MEDIA_ID_PREFIX } from './createPreviewMediaUploader'
88
+ export type { CreatePreviewMediaUploaderParams, PreviewMediaUploadRequest } from './createPreviewMediaUploader'
@@ -0,0 +1,61 @@
1
+ /**
2
+ * O teste que importa aqui é o do prefixo: ele é duplicado entre este pacote (UI) e o
3
+ * meta-whatsapp-module (backend), e divergir significa o simulador gerar um id que o servidor não
4
+ * reconhece — o áudio chega, o backend busca na Meta, volta 404 e ninguém entende por quê.
5
+ */
6
+
7
+ import { describe, expect, it } from 'bun:test'
8
+ import { PREVIEW_MEDIA_ID_PREFIX as CONTRACT_PREFIX } from '@adatechnology/meta-whatsapp-contracts'
9
+
10
+ import { createPreviewMediaUploader, PREVIEW_MEDIA_ID_PREFIX } from './createPreviewMediaUploader'
11
+
12
+ describe('createPreviewMediaUploader', () => {
13
+ it('reexporta o prefixo do contrato, sem cópia própria', () => {
14
+ expect(PREVIEW_MEDIA_ID_PREFIX).toBe(CONTRACT_PREFIX)
15
+ })
16
+
17
+ it('devolve o id prefixado com o uploadId da rota', async () => {
18
+ const upload = createPreviewMediaUploader({ upload: async () => ({ uploadId: 'chave/do/objeto' }) })
19
+
20
+ const result = await upload(new File([new Uint8Array([1, 2, 3])], 'nota.ogg', { type: 'audio/ogg' }))
21
+
22
+ expect(result.mediaId).toBe(`${CONTRACT_PREFIX}chave/do/objeto`)
23
+ expect(result.mimeType).toBe('audio/ogg')
24
+ expect(result.filename).toBe('nota.ogg')
25
+ })
26
+
27
+ it('manda o binário em base64 para a rota', async () => {
28
+ let recebido: string | undefined
29
+ const upload = createPreviewMediaUploader({
30
+ upload: async (request) => {
31
+ recebido = request.base64
32
+ return { uploadId: 'k' }
33
+ },
34
+ })
35
+
36
+ await upload(new File([new TextEncoder().encode('audio')], 'a.ogg', { type: 'audio/ogg' }))
37
+
38
+ expect(atob(recebido!)).toBe('audio')
39
+ })
40
+
41
+ // Gravador entrega áudio sem nome, e navegador antigo entrega sem mime.
42
+ it('preenche nome e mime quando o arquivo vem sem eles', async () => {
43
+ const upload = createPreviewMediaUploader({ upload: async () => ({ uploadId: 'k' }) })
44
+
45
+ const result = await upload(new File([new Uint8Array([1])], '', { type: '' }))
46
+
47
+ expect(result.mimeType).toBe('audio/ogg')
48
+ expect(result.filename).toBe('audio.ogg')
49
+ })
50
+
51
+ /**
52
+ * Espalhar o array de bytes como argumentos estoura a pilha em arquivo grande — o erro que só
53
+ * aparece na primeira gravação de verdade, nunca no clipe curto do teste.
54
+ */
55
+ it('converte arquivo grande sem estourar a pilha', async () => {
56
+ const upload = createPreviewMediaUploader({ upload: async () => ({ uploadId: 'k' }) })
57
+ const grande = new File([new Uint8Array(300_000)], 'longo.ogg', { type: 'audio/ogg' })
58
+
59
+ expect((await upload(grande)).mediaId).toContain(CONTRACT_PREFIX)
60
+ })
61
+ })
@@ -1,4 +1,4 @@
1
- import { createContext, useContext, type ReactNode } from 'react'
1
+ import { createContext, useContext, useMemo, type ReactNode } from 'react'
2
2
  import type { ConversationsApi, SSEProvider } from './types'
3
3
 
4
4
  interface ConversationsContextValue {
@@ -17,11 +17,13 @@ export function ConversationsProvider({
17
17
  sse: SSEProvider
18
18
  children: ReactNode
19
19
  }) {
20
- return (
21
- <ConversationsContext.Provider value={{ api, sse }}>
22
- {children}
23
- </ConversationsContext.Provider>
24
- )
20
+ // Objeto novo a cada render fazia todo efeito que depende do contexto reexecutar junto — o do
21
+ // `useConversationRealtime` fecha e reabre o SSE da conversa, e cada reabertura pede um ticket
22
+ // novo. Uma tela que renderiza em rajada saturava as 6 conexões do navegador com requisições
23
+ // pendentes que nunca chegavam a servir para nada.
24
+ const value = useMemo(() => ({ api, sse }), [api, sse])
25
+
26
+ return <ConversationsContext.Provider value={value}>{children}</ConversationsContext.Provider>
25
27
  }
26
28
 
27
29
  export function useConversations(): ConversationsContextValue | null {
@@ -1,4 +1,4 @@
1
- import type { MessagePayload } from '../types'
1
+ import type { MessagePayload, MessageTranscription } from '../types'
2
2
  import type { ConversationChannel } from '../conversationChannel'
3
3
 
4
4
  export interface ListConversationsParams {
@@ -31,14 +31,38 @@ export interface ListDocumentsParams {
31
31
  page?: number
32
32
  /** Tamanho da página. Sem ele, `page` sozinho não define fatia nenhuma. */
33
33
  limit?: number
34
- /** Origem do arquivo (`customer`, `agent`, `bot`…). O vocabulário é do host. */
34
+ /**
35
+ * Origem do arquivo (`customer`, `agent`, `bot`…). O vocabulário é do host.
36
+ *
37
+ * Seleção múltipla viaja como lista separada por vírgula em vez de virar `string[]`: mudar o
38
+ * tipo quebraria em compile-time toda implementação de host que já repassa este campo adiante,
39
+ * e o ganho seria nenhum — quem recebe faz `split(',')`.
40
+ */
35
41
  source?: string
42
+ /** Categoria do arquivo (`document`, `image`, `audio`, `video`…), mesma convenção de lista. */
43
+ fileCategory?: string
44
+ /** Recorte por data de recebimento, em `YYYY-MM-DD`. */
45
+ startDate?: string
46
+ endDate?: string
36
47
  sortDirection?: 'asc' | 'desc'
48
+ /** Coluna ordenada. Ausente, o host ordena pela data — é o padrão de toda listagem de arquivo. */
49
+ sortField?: string
50
+ /**
51
+ * Filtros que só existem no produto (`clientId`, `unidade`…). O pacote não os interpreta: passa
52
+ * adiante o que o host injetou pelo slot de filtros. É a porta que evita um fork da tela por
53
+ * causa de um `<select>`.
54
+ */
55
+ extra?: Readonly<Record<string, string | number>>
37
56
  }
38
57
 
39
58
  /** Arquivo na biblioteca da empresa: o mesmo da conversa, mais de qual conversa veio. */
40
59
  export interface CompanyDocument extends ConversationDocument {
41
60
  conversationId: string
61
+ /**
62
+ * Nome de quem enviou, quando o host o conhece. Opcional porque a biblioteca sempre tem o
63
+ * telefone e nem todo produto tem cadastro por trás dele — ausente, a coluna cai para o número.
64
+ */
65
+ contactName?: string | null
42
66
  }
43
67
 
44
68
  export interface CompanyDocumentPage {
@@ -110,6 +134,28 @@ export interface ConversationsApi {
110
134
  * componente de biblioteca simplesmente não é usável — melhor que uma tela que sempre erra.
111
135
  */
112
136
  getAllDocuments?(params?: ListDocumentsParams): Promise<CompanyDocumentPage>
137
+ /**
138
+ * Remove um arquivo da biblioteca. **Opcional por capacidade:** apagar anexo trocado com o
139
+ * cliente é decisão de retenção do produto — instalação que precisa guardar tudo por obrigação
140
+ * legal não implementa, e a tela simplesmente não desenha a lixeira.
141
+ */
142
+ deleteDocument?(uploadId: string): Promise<void>
143
+ /**
144
+ * Zip de arquivos avulsos da biblioteca, sem conversa de origem única — irmão do
145
+ * `downloadDocumentsArchive`, que é por conversa. Ausente, a seleção em lote não oferece o botão.
146
+ */
147
+ downloadDocumentsArchiveByIds?(uploadIds: readonly string[]): Promise<Blob>
148
+ /**
149
+ * Envia um arquivo avulso direto pra biblioteca, fora do fluxo de uma conversa. **Opcional por
150
+ * capacidade:** cada host tem seu próprio contrato de upload (base64, multipart, presigned URL) —
151
+ * o pacote não escolhe um formato de payload, só entrega o `File` do input e deixa o host montar
152
+ * a chamada do jeito que seu backend espera. Ausente, a tela de biblioteca não desenha o botão de
153
+ * enviar, em vez de oferecer uma ação que sempre falha.
154
+ *
155
+ * `extra` é o mesmo vocabulário livre do produto que já viaja em `renderFilters` — cliente,
156
+ * unidade, campanha — pra associar o arquivo enviado ao contexto que a tela estava filtrando.
157
+ */
158
+ uploadDocument?(file: File, extra?: Readonly<Record<string, string | number>>): Promise<ConversationDocument>
113
159
  getMediaProxyUrl(mediaId: string): Promise<{ mimeType: string; data: string }>
114
160
 
115
161
  /**
@@ -134,6 +180,17 @@ export interface ConversationsApi {
134
180
  * nem todo backend expõe a rota — quem não tem continua usando o builder local.
135
181
  */
136
182
  exportTranscript?(conversationId: string): Promise<{ transcript: string; filename: string }>
183
+
184
+ /**
185
+ * Transcreve o áudio de uma mensagem e devolve o resultado.
186
+ *
187
+ * **Opcional por capacidade.** Um host em modo automático transcreve na ingestão e não expõe rota
188
+ * nenhuma; um host sem engine configurado não transcreve de jeito algum. Nos dois casos o balão
189
+ * simplesmente não desenha o botão, em vez de oferecer uma ação que estoura no clique.
190
+ *
191
+ * `messageId` e não `conversationId`: transcrição é por áudio, e uma conversa tem vários.
192
+ */
193
+ transcribeAudio?(messageId: string): Promise<MessageTranscription>
137
194
  }
138
195
 
139
196
  /**
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Guarda a interpolação das mensagens rápidas.
3
+ *
4
+ * O caso que decide o desenho: variável ausente. Deixar `{{nome}}` no texto significa o atendente
5
+ * mandar "Olá {{nome}}!" para o cliente — pior que a saudação sem nome.
6
+ */
7
+
8
+ import { describe, expect, it } from 'bun:test'
9
+
10
+ import { applyQuickReplyVariables, resolveQuickReply } from './MessageComposer'
11
+
12
+ describe('applyQuickReplyVariables', () => {
13
+ it('troca a variável pelo valor', () => {
14
+ expect(applyQuickReplyVariables('Olá {{nome}}!', { nome: 'Marina' })).toBe('Olá Marina!')
15
+ })
16
+
17
+ it('aceita espaço dentro das chaves', () => {
18
+ expect(applyQuickReplyVariables('Olá {{ nome }}!', { nome: 'Rita' })).toBe('Olá Rita!')
19
+ })
20
+
21
+ it('troca todas as ocorrências', () => {
22
+ expect(applyQuickReplyVariables('{{nome}}, confirma? Obrigado, {{nome}}.', { nome: 'Ana' })).toBe(
23
+ 'Ana, confirma? Obrigado, Ana.',
24
+ )
25
+ })
26
+
27
+ // Nunca vaza o literal para o cliente.
28
+ it('apaga a variável que não foi passada', () => {
29
+ expect(applyQuickReplyVariables('Olá {{nome}}!', {})).toBe('Olá !')
30
+ expect(applyQuickReplyVariables('Olá {{nome}}!')).toBe('Olá !')
31
+ })
32
+
33
+ it('não mexe em texto sem variável', () => {
34
+ expect(applyQuickReplyVariables('Bom dia!', { nome: 'X' })).toBe('Bom dia!')
35
+ })
36
+ })
37
+
38
+ describe('resolveQuickReply', () => {
39
+ it('interpola quando o texto é string', () => {
40
+ const resolvido = resolveQuickReply({ key: 'g', label: '👋', text: 'Olá {{nome}}!' }, { nome: 'Rita' })
41
+
42
+ expect(resolvido).toBe('Olá Rita!')
43
+ })
44
+
45
+ // A função existe para o que a string não resolve: escolher copy por produto, pluralizar, formatar.
46
+ it('chama a função com as variáveis', () => {
47
+ const resolvido = resolveQuickReply(
48
+ { key: 's', label: '📋', text: (variables) => `Status de ${variables['produto'] ?? 'seu pedido'}` },
49
+ { produto: 'financiamento' },
50
+ )
51
+
52
+ expect(resolvido).toBe('Status de financiamento')
53
+ })
54
+
55
+ it('função sem variáveis não quebra', () => {
56
+ expect(resolveQuickReply({ key: 'c', label: '📞', text: () => 'Posso ligar?' })).toBe('Posso ligar?')
57
+ })
58
+ })
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Copyright (c) 2026 Ada Technology. MIT License.
3
+ *
4
+ * A distinção que estes testes protegem: janela de sessão e tempo sem resposta medem coisas
5
+ * diferentes, e reaproveitar `windowOf` para o SLA marcaria como atrasada toda conversa já
6
+ * respondida — o alerta apontaria para tudo, ou seja, para nada.
7
+ */
8
+
9
+ import { describe, expect, it } from 'bun:test'
10
+
11
+ import { REPLY_LATENCY, isReplyOverdue, replyLatencyOf } from './replyLatency'
12
+
13
+ const NOW = new Date('2026-08-29T12:00:00Z').getTime()
14
+ const hoursAgo = (hours: number) => new Date(NOW - hours * 60 * 60 * 1000).toISOString()
15
+
16
+ describe('faixas de espera', () => {
17
+ it('até 6h está dentro do combinado', () => {
18
+ expect(replyLatencyOf({ lastDirection: 'inbound', lastInboundAt: hoursAgo(0), now: NOW })).toBe(
19
+ REPLY_LATENCY.WITHIN,
20
+ )
21
+ expect(replyLatencyOf({ lastDirection: 'inbound', lastInboundAt: hoursAgo(5.9), now: NOW })).toBe(
22
+ REPLY_LATENCY.WITHIN,
23
+ )
24
+ })
25
+
26
+ it('de 6h a 12h já passou do combinado', () => {
27
+ expect(replyLatencyOf({ lastDirection: 'inbound', lastInboundAt: hoursAgo(6), now: NOW })).toBe(REPLY_LATENCY.LATE)
28
+ expect(replyLatencyOf({ lastDirection: 'inbound', lastInboundAt: hoursAgo(11.9), now: NOW })).toBe(
29
+ REPLY_LATENCY.LATE,
30
+ )
31
+ })
32
+
33
+ it('acima de 12h é crítico — não se confunde com uma espera de 7h', () => {
34
+ expect(replyLatencyOf({ lastDirection: 'inbound', lastInboundAt: hoursAgo(12), now: NOW })).toBe(
35
+ REPLY_LATENCY.CRITICAL,
36
+ )
37
+ expect(replyLatencyOf({ lastDirection: 'inbound', lastInboundAt: hoursAgo(72), now: NOW })).toBe(
38
+ REPLY_LATENCY.CRITICAL,
39
+ )
40
+ })
41
+ })
42
+
43
+ describe('quando não há espera a mostrar', () => {
44
+ it('conversa já respondida não tem selo, por mais antiga que seja', () => {
45
+ // É aqui que o SLA se separa da janela de sessão: `windowOf` marcaria isto como crítico.
46
+ expect(replyLatencyOf({ lastDirection: 'outbound', lastInboundAt: hoursAgo(20), now: NOW })).toBeNull()
47
+ })
48
+
49
+ it('cliente que nunca escreveu não tem espera', () => {
50
+ expect(replyLatencyOf({ lastDirection: 'inbound', lastInboundAt: null, now: NOW })).toBeNull()
51
+ })
52
+
53
+ it('direção desconhecida não afirma espera', () => {
54
+ expect(replyLatencyOf({ lastInboundAt: hoursAgo(20), now: NOW })).toBeNull()
55
+ })
56
+
57
+ it('relógio adiantado não vira conversa crítica', () => {
58
+ expect(replyLatencyOf({ lastDirection: 'inbound', lastInboundAt: hoursAgo(-0.05), now: NOW })).toBe(
59
+ REPLY_LATENCY.WITHIN,
60
+ )
61
+ })
62
+ })
63
+
64
+ describe('o que merece alerta', () => {
65
+ it('só o que passou de 6h', () => {
66
+ expect(isReplyOverdue(REPLY_LATENCY.WITHIN)).toBe(false)
67
+ expect(isReplyOverdue(REPLY_LATENCY.LATE)).toBe(true)
68
+ expect(isReplyOverdue(REPLY_LATENCY.CRITICAL)).toBe(true)
69
+ expect(isReplyOverdue(null)).toBe(false)
70
+ })
71
+ })
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Copyright (c) 2026 Ada Technology. MIT License.
3
+ *
4
+ * Há quanto tempo o cliente espera resposta.
5
+ *
6
+ * Não confundir com `conversationWindow`: aquilo é a janela de sessão da plataforma (o que o canal
7
+ * ainda deixa enviar), isto é serviço (quanto o cliente esperou). Os dois divergem justamente no
8
+ * caso que interessa — respondida a conversa, o relógio do SLA para e o da janela continua correndo.
9
+ *
10
+ * A conta sai de dados que a listagem já traz: se a última mensagem foi do cliente, ninguém
11
+ * respondeu ainda e a espera conta desde ela. Se a última foi nossa, não há espera pendente.
12
+ */
13
+
14
+ const HOUR_MS = 60 * 60 * 1000
15
+
16
+ export const REPLY_LATENCY = {
17
+ /** Até 6h — dentro do combinado. */
18
+ WITHIN: 'within',
19
+ /** 6h a 12h — passou do combinado. */
20
+ LATE: 'late',
21
+ /** Acima de 12h. */
22
+ CRITICAL: 'critical',
23
+ } as const
24
+ export type ReplyLatency = (typeof REPLY_LATENCY)[keyof typeof REPLY_LATENCY]
25
+
26
+ export const REPLY_LATENCY_LATE_HOURS = 6
27
+ export const REPLY_LATENCY_CRITICAL_HOURS = 12
28
+
29
+ export type ReplyLatencyParams = {
30
+ /** Direção da última mensagem. Ausente = desconhecida, e aí não se afirma espera. */
31
+ readonly lastDirection?: 'inbound' | 'outbound' | undefined
32
+ readonly lastInboundAt: string | null
33
+ readonly now: number
34
+ }
35
+
36
+ /**
37
+ * `null` quando não há espera a mostrar — conversa já respondida, ou cliente que nunca escreveu.
38
+ *
39
+ * Devolver uma faixa nesses casos encheria a lista de selos em conversa que não deve nada, e o
40
+ * alerta que aponta para todo lado não aponta para lugar nenhum.
41
+ */
42
+ export function replyLatencyOf(params: ReplyLatencyParams): ReplyLatency | null {
43
+ if (params.lastDirection !== 'inbound') return null
44
+ if (!params.lastInboundAt) return null
45
+
46
+ const elapsedHours = (params.now - new Date(params.lastInboundAt).getTime()) / HOUR_MS
47
+ // Espera negativa é relógio fora de sincronia entre servidor e navegador, não conversa do futuro:
48
+ // tratar como recém-chegada evita um selo "crítico" nascido de alguns segundos de diferença.
49
+ if (elapsedHours < REPLY_LATENCY_LATE_HOURS) return REPLY_LATENCY.WITHIN
50
+ if (elapsedHours < REPLY_LATENCY_CRITICAL_HOURS) return REPLY_LATENCY.LATE
51
+ return REPLY_LATENCY.CRITICAL
52
+ }
53
+
54
+ /** Só o que passou do combinado merece alerta — é o corte que a lista usa para destacar. */
55
+ export function isReplyOverdue(latency: ReplyLatency | null): boolean {
56
+ return latency === REPLY_LATENCY.LATE || latency === REPLY_LATENCY.CRITICAL
57
+ }