@adatechnology/conversations-ui 0.1.0-rc.20 → 0.1.0-rc.21

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.
@@ -1,7 +1,8 @@
1
- import { G as MessagePayload, o as ConversationSummary, m as ConversationEventSource, q as ConversationsApi, L as ListConversationsParams, n as ConversationPage, S as SSEProvider, k as ConversationDocument, K as ResolveMediaUrl } from '../types-O7kMP1Yn.js';
2
- export { A as AudioRecorderButton, a as AudioRecorderButtonLabels, b as AudioRecorderButtonProps, D as DEFAULT_AUDIO_RECORDER_BUTTON_LABELS } from '../types-O7kMP1Yn.js';
1
+ import { G as MessagePayload, o as ConversationSummary, m as ConversationEventSource, q as ConversationsApi, L as ListConversationsParams, n as ConversationPage, S as SSEProvider, k as ConversationDocument, N as ResolveMediaUrl } from '../types-C6A_9edv.js';
2
+ export { A as AudioRecorderButton, a as AudioRecorderButtonLabels, b as AudioRecorderButtonProps, D as DEFAULT_AUDIO_RECORDER_BUTTON_LABELS } from '../types-C6A_9edv.js';
3
3
  import * as react from 'react';
4
4
  import { InteractiveReplyOption, InboundMediaType } from '@adatechnology/meta-whatsapp-contracts/testing';
5
+ export { PREVIEW_MEDIA_ID_PREFIX } from '@adatechnology/meta-whatsapp-contracts';
5
6
 
6
7
  /**
7
8
  * Estado em memória que alimenta o preview de atendimento humano. Mock de API e mock de SSE
@@ -112,6 +113,52 @@ declare const PREVIEW_CONVERSATIONS: readonly ConversationSummary[];
112
113
  declare const PREVIEW_MESSAGES: Readonly<Record<string, readonly MessagePayload[]>>;
113
114
  declare const PREVIEW_DOCUMENTS: Readonly<Record<string, readonly ConversationDocument[]>>;
114
115
 
116
+ /**
117
+ * Entrega ao simulador o `uploadMedia` que ele precisa para desenhar o microfone.
118
+ *
119
+ * O `ConversationPreview` esconde o gravador sem esta função, e com razão: microfone que grava sem
120
+ * ter onde guardar o arquivo faz o operador falar para o vazio. O que faltava era montar isto —
121
+ * lê o `File`, manda para a rota do host, devolve o `mediaId` prefixado que o webhook referencia.
122
+ *
123
+ * Fica no pacote porque a parte que erra é sempre a mesma em todo produto: converter o binário sem
124
+ * estourar a pilha e marcar o id com o prefixo que o backend reconhece. O que muda por produto é só
125
+ * a rota e o cliente HTTP — e é exatamente isso que entra por parâmetro.
126
+ */
127
+ /**
128
+ * Do `contracts`, que este pacote já consome — não uma cópia.
129
+ *
130
+ * A convenção tem duas pontas (o front gera o id, o backend resolve) e a versão anterior disso vivia
131
+ * duplicada em dois pacotes de um produto, cada cópia com um comentário pedindo para não divergir.
132
+ * Contrato compartilhado é o que o `contracts` existe para guardar.
133
+ */
134
+
135
+ type PreviewUploadedMedia$1 = {
136
+ readonly mediaId: string;
137
+ readonly mimeType?: string;
138
+ readonly filename?: string;
139
+ };
140
+ type PreviewMediaUploadRequest = {
141
+ readonly base64: string;
142
+ readonly mimeType: string;
143
+ readonly filename: string;
144
+ };
145
+ type CreatePreviewMediaUploaderParams = {
146
+ /**
147
+ * Envia o arquivo à rota do host e devolve o `uploadId` (sem prefixo) que o backend gerou.
148
+ *
149
+ * Recebe a função inteira, e não uma URL, porque autenticação varia: uma instalação assina com
150
+ * HMAC, outra manda token de admin, outra usa cookie de sessão. Pedir a URL obrigaria o pacote a
151
+ * escolher por elas.
152
+ */
153
+ readonly upload: (request: PreviewMediaUploadRequest) => Promise<{
154
+ uploadId: string;
155
+ }>;
156
+ /** Nome usado quando o gravador entrega o áudio sem nome próprio. */
157
+ readonly fallbackFilename?: string;
158
+ readonly fallbackMimeType?: string;
159
+ };
160
+ declare function createPreviewMediaUploader(params: CreatePreviewMediaUploaderParams): (file: File) => Promise<PreviewUploadedMedia$1>;
161
+
115
162
  /**
116
163
  * Cliente que entrega mensagens do preview no webhook real, assinadas com HMAC — a mesma validação
117
164
  * de staging e produção, sem rota alternativa e sem bypass. Do ponto de vista da API, este cliente
@@ -136,6 +183,19 @@ type PreviewWebhookClient = {
136
183
  sendListReply(reply: InteractiveReplyOption): Promise<void>;
137
184
  sendAudio(mediaId: string): Promise<void>;
138
185
  sendMedia(params: SendPreviewMediaParams): Promise<void>;
186
+ /**
187
+ * Guarda um arquivo gravado e devolve o `mediaId` já prefixado, pronto para `sendMedia`.
188
+ *
189
+ * Existe no cliente, e não como prop de quem monta a tela, porque isto é exatamente o que ele já
190
+ * sabe fazer: falar com ESTE host usando ESTE segredo. Enquanto era responsabilidade do produto,
191
+ * o resultado prático foi um produto com microfone no simulador e outro sem — não por decisão,
192
+ * por esquecimento. Cliente montado, microfone na tela.
193
+ *
194
+ * Opcional porque o cliente-ponte só consegue oferecer isto quando sabe a rota de mídia (ou quando
195
+ * o host injeta a função): sem destino, gravar áudio seria falar para o vazio, e aí a tela
196
+ * corretamente não desenha o gravador.
197
+ */
198
+ uploadMedia?(file: File): Promise<PreviewUploadedMedia$1>;
139
199
  };
140
200
  type SendPreviewMediaParams = {
141
201
  readonly mediaType: InboundMediaType;
@@ -154,8 +214,20 @@ type CreatePreviewWebhookClientParams = {
154
214
  readonly appSecret: string;
155
215
  readonly from: string;
156
216
  readonly phoneNumberId?: string;
217
+ /**
218
+ * Rota que guarda o áudio gravado. Por padrão, `/v1/preview/media` na mesma origem do webhook.
219
+ *
220
+ * O padrão cobre o caso normal — as duas rotas são do mesmo servidor — e a prop existe para quem
221
+ * publica a API em outro host ou versiona o caminho.
222
+ */
223
+ readonly mediaUploadUrl?: string;
157
224
  readonly fetchImplementation?: typeof fetch;
158
225
  };
226
+ /** Falha da rota de upload, separada da do webhook: os dois lados quebram por motivos diferentes. */
227
+ declare class PreviewMediaUploadRejectedError extends Error {
228
+ readonly status: number;
229
+ constructor(status: number);
230
+ }
159
231
  declare class PreviewInProductionError extends Error {
160
232
  constructor();
161
233
  }
@@ -180,6 +252,18 @@ declare function signPreviewPayload(params: {
180
252
  rawBody: string;
181
253
  appSecret: string;
182
254
  }): Promise<string>;
255
+ declare const DEFAULT_MEDIA_UPLOAD_PATH = "/v1/preview/media";
256
+ /**
257
+ * O POST de mídia, sem a parte de assinatura — para os dois clientes usarem o mesmo caminho.
258
+ *
259
+ * O cliente-ponte autentica por sessão e o de webhook por HMAC; o que não muda é a rota, o formato
260
+ * do corpo e a leitura do `uploadId`. Duas cópias disso é como o prefixo de mídia divergiu antes.
261
+ */
262
+ declare function createPreviewMediaPoster(params: {
263
+ readonly url: string;
264
+ readonly headers?: (mimeType: string) => Promise<Readonly<Record<string, string>>>;
265
+ readonly fetchImplementation?: typeof fetch;
266
+ }): (file: File) => Promise<PreviewUploadedMedia$1>;
183
267
  declare function createPreviewWebhookClient(params: CreatePreviewWebhookClientParams): PreviewWebhookClient;
184
268
 
185
269
  type ConversationPreviewProps = {
@@ -200,6 +284,7 @@ type ConversationPreviewProps = {
200
284
  * inventa um endpoint de upload. Ausente, o compositor não oferece anexo nem gravação: melhor um
201
285
  * botão que não existe do que um que falha ao ser tocado.
202
286
  */
287
+ /** Destino alternativo do áudio gravado. Sem isto, usa o do próprio `client`. */
203
288
  uploadMedia?: (file: File) => Promise<PreviewUploadedMedia>;
204
289
  };
205
290
  type PreviewUploadedMedia = {
@@ -269,6 +354,18 @@ type CreatePreviewBridgeClientParams = {
269
354
  readonly endpointUrl?: string;
270
355
  readonly headers?: Readonly<Record<string, string>>;
271
356
  readonly fetchImplementation?: typeof fetch;
357
+ /**
358
+ * Rota que guarda o áudio gravado. Por padrão, `/v1/preview/media` na origem do `endpointUrl`.
359
+ *
360
+ * Aqui não há assinatura a calcular: a ponte existe justamente para não ter segredo no navegador,
361
+ * e a rota é protegida pela sessão do painel — os mesmos `headers` do comando valem para o upload.
362
+ */
363
+ readonly mediaUploadUrl?: string;
364
+ /**
365
+ * Substitui o upload embutido. Necessário para host que só passa `sendCommand`: sem `endpointUrl`
366
+ * não há origem a derivar, e sem destino o gravador não é desenhado.
367
+ */
368
+ readonly uploadMedia?: (file: File) => Promise<PreviewUploadedMedia$1>;
272
369
  };
273
370
  declare function createPreviewBridgeClient(params: CreatePreviewBridgeClientParams): PreviewWebhookClient;
274
371
 
@@ -370,4 +467,4 @@ type MediaTypesPreviewProps = {
370
467
  };
371
468
  declare function MediaTypesPreview({ conversationId, className, }: MediaTypesPreviewProps): react.JSX.Element;
372
469
 
373
- export { type AppendMessageParams, ConversationPreview, type ConversationPreviewProps, type CreateMockConversationsApiParams, type CreateMockSSEProviderParams, type CreatePreviewBridgeClientParams, type CreatePreviewStoreParams, type CreatePreviewWebhookClientParams, DEFAULT_PREVIEW_SCRIPT, GLOBAL_CHANNEL, type ListConversationsFilters, MEDIA_TYPES_CONVERSATION_ID, MediaTypesPreview, type MediaTypesPreviewProps, type MockEventSource, PREVIEW_CONVERSATIONS, PREVIEW_DOCUMENTS, PREVIEW_FILE_SAMPLES, PREVIEW_MESSAGES, PreviewBridgeRejectedError, type PreviewEmission, PreviewInProductionError, type PreviewInboundCommand, type PreviewScriptStep, type PreviewStore, type PreviewStoreListener, type PreviewUploadedMedia, type PreviewWebhookClient, PreviewWebhookRejectedError, type SendPreviewInboundCommand, type SendPreviewMediaParams, type SetModeParams, type StartPreviewScriptParams, assertPreviewEnvironment, conversationChannel, createMockConversationsApi, createMockEventSource, createMockSSEProvider, createPreviewBridgeClient, createPreviewMediaResolver, createPreviewStore, createPreviewWebhookClient, mediaTypeOf, previewFileBase64, previewFileUrl, resolvePreviewFileSample, signPreviewPayload, startPreviewScript };
470
+ export { type AppendMessageParams, ConversationPreview, type ConversationPreviewProps, type CreateMockConversationsApiParams, type CreateMockSSEProviderParams, type CreatePreviewBridgeClientParams, type CreatePreviewMediaUploaderParams, type CreatePreviewStoreParams, type CreatePreviewWebhookClientParams, DEFAULT_MEDIA_UPLOAD_PATH, DEFAULT_PREVIEW_SCRIPT, GLOBAL_CHANNEL, type ListConversationsFilters, MEDIA_TYPES_CONVERSATION_ID, MediaTypesPreview, type MediaTypesPreviewProps, type MockEventSource, PREVIEW_CONVERSATIONS, PREVIEW_DOCUMENTS, PREVIEW_FILE_SAMPLES, PREVIEW_MESSAGES, PreviewBridgeRejectedError, type PreviewEmission, PreviewInProductionError, type PreviewInboundCommand, PreviewMediaUploadRejectedError, type PreviewMediaUploadRequest, type PreviewScriptStep, type PreviewStore, type PreviewStoreListener, type PreviewUploadedMedia, type PreviewWebhookClient, PreviewWebhookRejectedError, type SendPreviewInboundCommand, type SendPreviewMediaParams, type SetModeParams, type StartPreviewScriptParams, assertPreviewEnvironment, conversationChannel, createMockConversationsApi, createMockEventSource, createMockSSEProvider, createPreviewBridgeClient, createPreviewMediaPoster, createPreviewMediaResolver, createPreviewMediaUploader, createPreviewStore, createPreviewWebhookClient, mediaTypeOf, previewFileBase64, previewFileUrl, resolvePreviewFileSample, signPreviewPayload, startPreviewScript };
@@ -8,7 +8,7 @@ import {
8
8
  DocumentsLibrary,
9
9
  MessageBubble,
10
10
  MessageComposer
11
- } from "../chunk-OIDAIVCH.js";
11
+ } from "../chunk-TV4OQRGH.js";
12
12
  import "../chunk-2AYDBWNE.js";
13
13
 
14
14
  // src/preview/previewStore.ts
@@ -980,11 +980,12 @@ function ConversationPreview({
980
980
  setFailure(error instanceof Error ? error.message : "Falha ao entregar a resposta no webhook.");
981
981
  }
982
982
  }
983
+ const uploadFile = uploadMedia ?? client.uploadMedia;
983
984
  async function handleAttach(file) {
984
- if (!uploadMedia) return;
985
+ if (!uploadFile) return;
985
986
  setFailure(void 0);
986
987
  try {
987
- const uploaded = await uploadMedia(file);
988
+ const uploaded = await uploadFile(file);
988
989
  await client.sendMedia({
989
990
  mediaType: mediaTypeOf(uploaded.mimeType ?? file.type),
990
991
  mediaId: uploaded.mediaId,
@@ -1018,9 +1019,9 @@ function ConversationPreview({
1018
1019
  MessageComposer,
1019
1020
  {
1020
1021
  onSend: (text) => void handleSend(text),
1021
- onAttach: uploadMedia ? (file) => void handleAttach(file) : void 0,
1022
+ onAttach: uploadFile ? (file) => void handleAttach(file) : void 0,
1022
1023
  placeholder: isRecording ? "Gravando\u2026 toque no quadrado para ouvir" : placeholder ?? "Escreva como o cliente\u2026",
1023
- idleAction: uploadMedia ? /* @__PURE__ */ jsx(
1024
+ idleAction: uploadFile ? /* @__PURE__ */ jsx(
1024
1025
  AudioRecorderButton,
1025
1026
  {
1026
1027
  onRecorded: (file) => void handleAttach(file),
@@ -1041,6 +1042,38 @@ import {
1041
1042
  buildInboundTextPayload,
1042
1043
  serializeWebhookPayload
1043
1044
  } from "@adatechnology/meta-whatsapp-contracts/testing";
1045
+
1046
+ // src/preview/createPreviewMediaUploader.ts
1047
+ import { PREVIEW_MEDIA_ID_PREFIX } from "@adatechnology/meta-whatsapp-contracts";
1048
+ import { toPreviewMediaId } from "@adatechnology/meta-whatsapp-contracts";
1049
+ var CHUNK_SIZE = 8192;
1050
+ async function fileToBase64(file) {
1051
+ const bytes = new Uint8Array(await file.arrayBuffer());
1052
+ let binary = "";
1053
+ for (let offset = 0; offset < bytes.length; offset += CHUNK_SIZE) {
1054
+ binary += String.fromCharCode(...bytes.subarray(offset, offset + CHUNK_SIZE));
1055
+ }
1056
+ return btoa(binary);
1057
+ }
1058
+ function createPreviewMediaUploader(params) {
1059
+ const fallbackMimeType = params.fallbackMimeType ?? "audio/ogg";
1060
+ const fallbackFilename = params.fallbackFilename ?? "audio.ogg";
1061
+ return async function uploadPreviewMedia(file) {
1062
+ const mimeType = file.type || fallbackMimeType;
1063
+ const filename = file.name || fallbackFilename;
1064
+ const { uploadId } = await params.upload({ base64: await fileToBase64(file), mimeType, filename });
1065
+ return { mediaId: toPreviewMediaId(uploadId), mimeType, filename };
1066
+ };
1067
+ }
1068
+
1069
+ // src/preview/createPreviewWebhookClient.ts
1070
+ var PreviewMediaUploadRejectedError = class extends Error {
1071
+ constructor(status) {
1072
+ super(`A rota de m\xEDdia do simulador recusou o upload (HTTP ${status}).`);
1073
+ this.status = status;
1074
+ this.name = "PreviewMediaUploadRejectedError";
1075
+ }
1076
+ };
1044
1077
  var PreviewInProductionError = class extends Error {
1045
1078
  constructor() {
1046
1079
  super("O preview de conversa carrega um app secret e n\xE3o pode ser montado em produ\xE7\xE3o.");
@@ -1070,6 +1103,30 @@ async function signPreviewPayload(params) {
1070
1103
  return `sha256=${[...new Uint8Array(signature)].map((byte) => byte.toString(16).padStart(2, "0")).join("")}`;
1071
1104
  }
1072
1105
  var signWithWebCrypto = signPreviewPayload;
1106
+ var DEFAULT_MEDIA_UPLOAD_PATH = "/v1/preview/media";
1107
+ function defaultMediaUploadUrl(webhookUrl) {
1108
+ try {
1109
+ return new URL(DEFAULT_MEDIA_UPLOAD_PATH, webhookUrl).toString();
1110
+ } catch {
1111
+ return DEFAULT_MEDIA_UPLOAD_PATH;
1112
+ }
1113
+ }
1114
+ function createPreviewMediaPoster(params) {
1115
+ return createPreviewMediaUploader({
1116
+ upload: async (request) => {
1117
+ const performRequest = params.fetchImplementation ?? fetch;
1118
+ const extraHeaders = await params.headers?.(request.mimeType) ?? {};
1119
+ const response = await performRequest(params.url, {
1120
+ method: "POST",
1121
+ headers: { "content-type": "application/json", ...extraHeaders },
1122
+ body: JSON.stringify(request)
1123
+ });
1124
+ if (!response.ok) throw new PreviewMediaUploadRejectedError(response.status);
1125
+ const body = await response.json();
1126
+ return { uploadId: body.data.uploadId };
1127
+ }
1128
+ });
1129
+ }
1073
1130
  function createPreviewWebhookClient(params) {
1074
1131
  const sendPayload = async (payload) => {
1075
1132
  const rawBody = serializeWebhookPayload(payload);
@@ -1082,13 +1139,21 @@ function createPreviewWebhookClient(params) {
1082
1139
  });
1083
1140
  if (!response.ok) throw new PreviewWebhookRejectedError(response.status);
1084
1141
  };
1142
+ const uploadMedia = createPreviewMediaPoster({
1143
+ url: params.mediaUploadUrl ?? defaultMediaUploadUrl(params.webhookUrl),
1144
+ headers: async (mimeType) => ({
1145
+ "x-preview-signature": await signWithWebCrypto({ rawBody: mimeType, appSecret: params.appSecret })
1146
+ }),
1147
+ ...params.fetchImplementation ? { fetchImplementation: params.fetchImplementation } : {}
1148
+ });
1085
1149
  const envelope = { from: params.from, phoneNumberId: params.phoneNumberId };
1086
1150
  return {
1087
1151
  sendText: (text) => sendPayload(buildInboundTextPayload({ ...envelope, text })),
1088
1152
  sendButtonReply: (reply) => sendPayload(buildInboundInteractivePayload({ ...envelope, buttonReply: reply })),
1089
1153
  sendListReply: (reply) => sendPayload(buildInboundInteractivePayload({ ...envelope, listReply: reply })),
1090
1154
  sendAudio: (mediaId) => sendPayload(buildInboundAudioPayload({ ...envelope, mediaId })),
1091
- sendMedia: (media) => sendPayload(buildInboundMediaPayload({ ...envelope, ...media }))
1155
+ sendMedia: (media) => sendPayload(buildInboundMediaPayload({ ...envelope, ...media })),
1156
+ uploadMedia
1092
1157
  };
1093
1158
  }
1094
1159
 
@@ -1117,15 +1182,27 @@ function buildFetchSender(params) {
1117
1182
  if (!response.ok) throw new PreviewBridgeRejectedError(response.status);
1118
1183
  };
1119
1184
  }
1185
+ function resolveBridgeUpload(params) {
1186
+ if (params.uploadMedia) return params.uploadMedia;
1187
+ const url = params.mediaUploadUrl ?? (params.endpointUrl ? defaultMediaUploadUrl(params.endpointUrl) : void 0);
1188
+ if (!url) return void 0;
1189
+ return createPreviewMediaPoster({
1190
+ url,
1191
+ ...params.headers ? { headers: async () => params.headers ?? {} } : {},
1192
+ ...params.fetchImplementation ? { fetchImplementation: params.fetchImplementation } : {}
1193
+ });
1194
+ }
1120
1195
  function createPreviewBridgeClient(params) {
1121
1196
  const send = params.sendCommand ?? buildFetchSender(params);
1122
1197
  const from = params.from;
1198
+ const uploadMedia = resolveBridgeUpload(params);
1123
1199
  return {
1124
1200
  sendText: (text) => send({ kind: "text", from, text }),
1125
1201
  sendButtonReply: (reply) => send({ kind: "buttonReply", from, reply }),
1126
1202
  sendListReply: (reply) => send({ kind: "listReply", from, reply }),
1127
1203
  sendAudio: (mediaId) => send({ kind: "audio", from, mediaId }),
1128
- sendMedia: (media) => send({ kind: "media", from, ...media })
1204
+ sendMedia: (media) => send({ kind: "media", from, ...media }),
1205
+ ...uploadMedia ? { uploadMedia } : {}
1129
1206
  };
1130
1207
  }
1131
1208
 
@@ -1218,6 +1295,7 @@ export {
1218
1295
  AudioRecorderButton,
1219
1296
  ConversationPreview,
1220
1297
  DEFAULT_AUDIO_RECORDER_BUTTON_LABELS,
1298
+ DEFAULT_MEDIA_UPLOAD_PATH,
1221
1299
  DEFAULT_PREVIEW_SCRIPT,
1222
1300
  GLOBAL_CHANNEL,
1223
1301
  MEDIA_TYPES_CONVERSATION_ID,
@@ -1225,9 +1303,11 @@ export {
1225
1303
  PREVIEW_CONVERSATIONS,
1226
1304
  PREVIEW_DOCUMENTS,
1227
1305
  PREVIEW_FILE_SAMPLES,
1306
+ PREVIEW_MEDIA_ID_PREFIX,
1228
1307
  PREVIEW_MESSAGES,
1229
1308
  PreviewBridgeRejectedError,
1230
1309
  PreviewInProductionError,
1310
+ PreviewMediaUploadRejectedError,
1231
1311
  PreviewWebhookRejectedError,
1232
1312
  assertPreviewEnvironment,
1233
1313
  conversationChannel,
@@ -1235,7 +1315,9 @@ export {
1235
1315
  createMockEventSource,
1236
1316
  createMockSSEProvider,
1237
1317
  createPreviewBridgeClient,
1318
+ createPreviewMediaPoster,
1238
1319
  createPreviewMediaResolver,
1320
+ createPreviewMediaUploader,
1239
1321
  createPreviewStore,
1240
1322
  createPreviewWebhookClient,
1241
1323
  mediaTypeOf,
package/dist/styles.css CHANGED
@@ -207,3 +207,33 @@
207
207
  max-width: calc(100vw - 1.5rem);
208
208
  }
209
209
  }
210
+ .cv-scrollbar-thin {
211
+ scrollbar-width: thin;
212
+ scrollbar-color: rgba(0, 0, 0, 0.15) transparent;
213
+ }
214
+ .cv-scrollbar-thin::-webkit-scrollbar {
215
+ width: 4px;
216
+ height: 4px;
217
+ }
218
+ .cv-scrollbar-thin::-webkit-scrollbar-track {
219
+ background: transparent;
220
+ }
221
+ .cv-scrollbar-thin::-webkit-scrollbar-thumb {
222
+ background: rgba(0, 0, 0, 0.15);
223
+ border-radius: 2px;
224
+ }
225
+ .cv-scrollbar-thin::-webkit-scrollbar-thumb:hover {
226
+ background: rgba(0, 0, 0, 0.3);
227
+ }
228
+ .dark .cv-scrollbar-thin {
229
+ scrollbar-color: rgba(255, 255, 255, 0.15) transparent;
230
+ }
231
+ .dark .cv-scrollbar-thin::-webkit-scrollbar-thumb {
232
+ background: rgba(255, 255, 255, 0.15);
233
+ }
234
+ .dark .cv-scrollbar-thin::-webkit-scrollbar-thumb:hover {
235
+ background: rgba(255, 255, 255, 0.3);
236
+ }
237
+ :where(button:not(:disabled), [role=button]:not(:disabled), summary) {
238
+ cursor: pointer;
239
+ }
@@ -90,19 +90,49 @@ interface MessagePayload {
90
90
  isOffensive: boolean;
91
91
  terms: string[];
92
92
  } | null;
93
+ /**
94
+ * Transcrição do áudio, vinda do backend — a UI só exibe, nunca transcreve. Rodar STT no browser
95
+ * exigiria baixar modelo por aba e daria resultado diferente por versão de cliente.
96
+ *
97
+ * `null`/ausente = não avaliado, que é diferente de `'done'` com texto vazio (áudio em silêncio,
98
+ * já processado). É essa distinção que decide se o balão oferece "transcrever" ou "sem fala
99
+ * detectada".
100
+ */
101
+ transcription?: MessageTranscription | null;
93
102
  isFirstInGroup?: boolean;
94
103
  isLastInGroup?: boolean;
95
104
  }
105
+ type TranscriptionStatus = 'pending' | 'done' | 'failed' | 'unsupported';
106
+ /**
107
+ * Quando transcrever, escolhido nas configurações da empresa. Espelha o
108
+ * `TranscriptionMode` de `@adatechnology/meta-whatsapp-contracts`; declarado aqui para o pacote de
109
+ * UI não obrigar quem só desenha telas a instalar os contratos do backend.
110
+ */
111
+ type TranscriptionMode = 'auto' | 'onDemand';
112
+ interface MessageTranscription {
113
+ status: TranscriptionStatus;
114
+ text?: string | null;
115
+ /** ISO 639-1 ou nome do idioma, conforme o engine. Exibido como dica, não interpretado. */
116
+ language?: string | null;
117
+ engine?: string | null;
118
+ }
96
119
 
97
120
  type ResolveMediaUrl = (message: MessagePayload) => Promise<string | null>;
98
121
  interface MediaRendererProps {
99
122
  message: MessagePayload;
100
123
  onLightbox: (src: string) => void;
101
124
  onResolveUrl?: ResolveMediaUrl;
125
+ /**
126
+ * Pede ao backend a transcrição do áudio desta mensagem. Ausente, o bloco de transcrição só exibe
127
+ * o que já veio pronto — sem oferecer um botão que o host não sabe atender.
128
+ *
129
+ * O que devolver é exibido na hora, sem esperar refetch da lista.
130
+ */
131
+ onTranscribeAudio?: () => Promise<MessageTranscription | void>;
102
132
  /** Aplicado no wrapper de cada tipo de mídia — imagem, vídeo, áudio e documento. */
103
133
  className?: string;
104
134
  }
105
- declare function MediaRenderer({ message, onLightbox, onResolveUrl, className }: MediaRendererProps): react.JSX.Element | null;
135
+ declare function MediaRenderer({ message, onLightbox, onResolveUrl, onTranscribeAudio, className, }: MediaRendererProps): react.JSX.Element | null;
106
136
 
107
137
  /**
108
138
  * Gravação de áudio no simulador, pelo microfone do próprio navegador.
@@ -358,6 +388,16 @@ interface ConversationsApi {
358
388
  transcript: string;
359
389
  filename: string;
360
390
  }>;
391
+ /**
392
+ * Transcreve o áudio de uma mensagem e devolve o resultado.
393
+ *
394
+ * **Opcional por capacidade.** Um host em modo automático transcreve na ingestão e não expõe rota
395
+ * nenhuma; um host sem engine configurado não transcreve de jeito algum. Nos dois casos o balão
396
+ * simplesmente não desenha o botão, em vez de oferecer uma ação que estoura no clique.
397
+ *
398
+ * `messageId` e não `conversationId`: transcrição é por áudio, e uma conversa tem vários.
399
+ */
400
+ transcribeAudio?(messageId: string): Promise<MessageTranscription>;
361
401
  }
362
402
  /**
363
403
  * Superfície mínima de stream que o pacote consome — exatamente o que `useConversationRealtime`
@@ -413,4 +453,4 @@ interface ConversationDocument {
413
453
  linkedAt: string;
414
454
  }
415
455
 
416
- export { AudioRecorderButton as A, type ListDocumentsParams as B, CHANNEL_CAPABILITIES as C, DEFAULT_AUDIO_RECORDER_BUTTON_LABELS as D, type MediaRendererProps as E, type FormatContactHandleParams as F, type MessagePayload as G, HANDLE_KIND as H, type InteractiveOption as I, type ReopenMechanism as J, type ResolveMediaUrl as K, type ListConversationsParams as L, MediaRenderer as M, capabilitiesOf as N, channelFiltersFor as O, contactFlag as P, formatContactHandle as Q, REOPEN_MECHANISM as R, type SSEProvider as S, type AudioRecorderButtonLabels as a, type AudioRecorderButtonProps as b, CHANNEL_FILTER_ALL as c, CONVERSATION_CHANNEL as d, type ChannelCapabilities as e, type ChannelFilter as f, type ChannelFilterOption as g, type CompanyDocument as h, type CompanyDocumentPage as i, type ConversationChannel as j, type ConversationDocument as k, type ConversationDocumentPage as l, type ConversationEventSource as m, type ConversationPage as n, type ConversationSummary as o, type ConversationTemplate as p, type ConversationsApi as q, type ConversationsFeatures as r, type ConversationsTheme as s, type ConversationsUIConfig as t, DEFAULT_CONVERSATION_CHANNEL as u, DEFAULT_MAX_RECORDING_MILLISECONDS as v, type HandleKind as w, type InteractivePayload as x, type InteractiveSection as y, type InteractiveSelection as z };
456
+ export { AudioRecorderButton as A, type ListDocumentsParams as B, CHANNEL_CAPABILITIES as C, DEFAULT_AUDIO_RECORDER_BUTTON_LABELS as D, type MediaRendererProps as E, type FormatContactHandleParams as F, type MessagePayload as G, HANDLE_KIND as H, type InteractiveOption as I, type MessageTranscription as J, type ReopenMechanism as K, type ListConversationsParams as L, MediaRenderer as M, type ResolveMediaUrl as N, type TranscriptionStatus as O, capabilitiesOf as P, channelFiltersFor as Q, REOPEN_MECHANISM as R, type SSEProvider as S, type TranscriptionMode as T, contactFlag as U, formatContactHandle as V, type AudioRecorderButtonLabels as a, type AudioRecorderButtonProps as b, CHANNEL_FILTER_ALL as c, CONVERSATION_CHANNEL as d, type ChannelCapabilities as e, type ChannelFilter as f, type ChannelFilterOption as g, type CompanyDocument as h, type CompanyDocumentPage as i, type ConversationChannel as j, type ConversationDocument as k, type ConversationDocumentPage as l, type ConversationEventSource as m, type ConversationPage as n, type ConversationSummary as o, type ConversationTemplate as p, type ConversationsApi as q, type ConversationsFeatures as r, type ConversationsTheme as s, type ConversationsUIConfig as t, DEFAULT_CONVERSATION_CHANNEL as u, DEFAULT_MAX_RECORDING_MILLISECONDS as v, type HandleKind as w, type InteractivePayload as x, type InteractiveSection as y, type InteractiveSelection as z };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adatechnology/conversations-ui",
3
- "version": "0.1.0-rc.20",
3
+ "version": "0.1.0-rc.21",
4
4
  "description": "WhatsApp conversation UI components — parametrizável por endpoint, tema e feature flags",
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -31,7 +31,7 @@
31
31
  "clsx": "^2.1.1",
32
32
  "lucide-react": "^1.21.0",
33
33
  "tailwind-merge": "^3.6.0",
34
- "@adatechnology/meta-whatsapp-contracts": "0.2.0-rc.8"
34
+ "@adatechnology/meta-whatsapp-contracts": "0.2.0-rc.9"
35
35
  },
36
36
  "peerDependencies": {
37
37
  "react": "^18 || ^19",
@@ -0,0 +1,115 @@
1
+ import { describe, expect, it } from 'bun:test'
2
+ import { renderToStaticMarkup } from 'react-dom/server'
3
+
4
+ import { AudioTranscription } from './AudioTranscription'
5
+ import type { MessageTranscription } from './types'
6
+
7
+ function render(props: {
8
+ transcription?: MessageTranscription | null
9
+ onTranscribe?: () => Promise<void>
10
+ }): string {
11
+ return renderToStaticMarkup(<AudioTranscription {...props} />)
12
+ }
13
+
14
+ const noop = async () => undefined
15
+
16
+ describe('AudioTranscription', () => {
17
+ it('mostra o texto e oferece copiar', () => {
18
+ const markup = render({ transcription: { status: 'done', text: 'quero dois pães na chapa' } })
19
+
20
+ expect(markup).toContain('quero dois pães na chapa')
21
+ expect(markup).toContain('Copiar')
22
+ })
23
+
24
+ it('deixa o texto selecionável — é o caminho de quem não tem clipboard disponível', () => {
25
+ const markup = render({ transcription: { status: 'done', text: 'oi' } })
26
+
27
+ expect(markup).toContain('select-all')
28
+ })
29
+
30
+ it('não oferece copiar quando não há texto para copiar', () => {
31
+ const markup = render({ transcription: { status: 'done', text: '' } })
32
+
33
+ expect(markup).not.toContain('Copiar')
34
+ })
35
+
36
+ // Silêncio processado é diferente de não transcrito: sem essa distinção o operador clica em
37
+ // transcrever de novo atrás de um texto que não existe.
38
+ it('diz "sem fala detectada" em áudio processado e vazio, sem oferecer transcrever', () => {
39
+ const markup = render({ transcription: { status: 'done', text: ' ' } })
40
+
41
+ expect(markup).toContain('Sem fala detectada')
42
+ expect(markup).not.toContain('Transcrever áudio')
43
+ })
44
+
45
+ it('oferece transcrever quando nada foi avaliado ainda', () => {
46
+ const markup = render({ transcription: null, onTranscribe: noop })
47
+
48
+ expect(markup).toContain('Transcrever áudio')
49
+ })
50
+
51
+ // Mesmo padrão de takeover/release: sem a porta, a afordância não existe — melhor que um botão
52
+ // que estoura no clique.
53
+ it('não desenha nada quando não há transcrição nem forma de pedir uma', () => {
54
+ expect(render({ transcription: null })).toBe('')
55
+ expect(render({})).toBe('')
56
+ })
57
+
58
+ it('mostra falha com convite a tentar de novo', () => {
59
+ const markup = render({ transcription: { status: 'failed' }, onTranscribe: noop })
60
+
61
+ expect(markup).toContain('Falha ao transcrever')
62
+ })
63
+
64
+ it('trata pendente como em andamento — já foi tentado e vai sair', () => {
65
+ const markup = render({ transcription: { status: 'pending' }, onTranscribe: noop })
66
+
67
+ expect(markup).toContain('Transcrevendo...')
68
+ })
69
+
70
+ it('avisa formato não suportado sem oferecer retry — retentar não conserta codec', () => {
71
+ const markup = render({ transcription: { status: 'unsupported' }, onTranscribe: noop })
72
+
73
+ expect(markup).toContain('não suportado')
74
+ expect(markup).not.toContain('Transcrever novamente')
75
+ })
76
+
77
+ /**
78
+ * Medido: 1147 caracteres produziram uma bolha de 854px, mais alta que a área visível da conversa.
79
+ * Sem o recolhimento, uma nota de voz longa esconde as mensagens seguintes.
80
+ */
81
+ it('recolhe transcrição longa e oferece ver o texto completo', () => {
82
+ const markup = render({ transcription: { status: 'done', text: 'palavra '.repeat(60).trim() } })
83
+
84
+ expect(markup).toContain('ver transcrição completa')
85
+ expect(markup).toContain('-webkit-line-clamp')
86
+ })
87
+
88
+ it('não recolhe transcrição curta', () => {
89
+ const markup = render({ transcription: { status: 'done', text: 'quero dois pães' } })
90
+
91
+ expect(markup).not.toContain('ver transcrição completa')
92
+ expect(markup).not.toContain('-webkit-line-clamp')
93
+ })
94
+
95
+ // Recolher é sobre altura na tela; o operador cola o pedido inteiro no sistema interno.
96
+ it('mantém o texto inteiro no DOM mesmo recolhido, para o copiar levar tudo', () => {
97
+ const markup = render({ transcription: { status: 'done', text: 'primeira ' + 'meio '.repeat(70) + 'ultima' } })
98
+
99
+ expect(markup).toContain('primeira')
100
+ expect(markup).toContain('ultima')
101
+ })
102
+
103
+ it('oferece retranscrever quando já há texto e o host sabe transcrever', () => {
104
+ const markup = render({ transcription: { status: 'done', text: 'ruim' }, onTranscribe: noop })
105
+
106
+ expect(markup).toContain('Transcrever novamente')
107
+ })
108
+
109
+ it('não oferece retranscrever quando o host não sabe transcrever', () => {
110
+ const markup = render({ transcription: { status: 'done', text: 'ok' } })
111
+
112
+ expect(markup).toContain('ok')
113
+ expect(markup).not.toContain('Transcrever novamente')
114
+ })
115
+ })