@adatechnology/conversations-ui 0.1.0-rc.13 → 0.1.0-rc.15

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.
@@ -1082,13 +1082,17 @@ var MessageComposer = ({
1082
1082
  };
1083
1083
 
1084
1084
  // src/AudioRecorderButton.tsx
1085
- import { useCallback as useCallback3, useRef as useRef3, useState as useState7 } from "react";
1085
+ import { useCallback as useCallback3, useEffect as useEffect2, useRef as useRef3, useState as useState7 } from "react";
1086
1086
  import { jsx as jsx13, jsxs as jsxs9 } from "react/jsx-runtime";
1087
1087
  var DEFAULT_AUDIO_RECORDER_BUTTON_LABELS = {
1088
1088
  start: "Gravar \xE1udio",
1089
1089
  stop: "Parar grava\xE7\xE3o",
1090
1090
  unsupported: "Este navegador n\xE3o grava \xE1udio.",
1091
- denied: "Sem permiss\xE3o para usar o microfone."
1091
+ denied: "Sem permiss\xE3o para usar o microfone.",
1092
+ review: "Ou\xE7a antes de enviar",
1093
+ send: "Enviar \xE1udio",
1094
+ discard: "Descartar \xE1udio",
1095
+ empty: "Nada foi captado pelo microfone."
1092
1096
  };
1093
1097
  var DEFAULT_MAX_RECORDING_MILLISECONDS = 5 * 60 * 1e3;
1094
1098
  var RECORDING_FORMATS = [
@@ -1105,15 +1109,28 @@ function AudioRecorderButton({
1105
1109
  onRecorded,
1106
1110
  onFailure,
1107
1111
  onRecordingChange,
1112
+ reviewBeforeSend = true,
1108
1113
  maxDurationMilliseconds = DEFAULT_MAX_RECORDING_MILLISECONDS,
1109
1114
  labels,
1110
1115
  disabled
1111
1116
  }) {
1112
- const startLabel = labels?.start ?? DEFAULT_AUDIO_RECORDER_BUTTON_LABELS.start;
1113
- const stopLabel = labels?.stop ?? DEFAULT_AUDIO_RECORDER_BUTTON_LABELS.stop;
1117
+ const labelOf = (key) => labels?.[key] ?? DEFAULT_AUDIO_RECORDER_BUTTON_LABELS[key];
1114
1118
  const [isRecording, setIsRecording] = useState7(false);
1119
+ const [pending, setPending] = useState7(void 0);
1115
1120
  const recorderRef = useRef3(null);
1116
1121
  const autoStopRef = useRef3(void 0);
1122
+ const discard = useCallback3(() => {
1123
+ setPending((current) => {
1124
+ if (current) URL.revokeObjectURL(current.objectURL);
1125
+ return void 0;
1126
+ });
1127
+ }, []);
1128
+ useEffect2(() => discard, [discard]);
1129
+ const confirm = useCallback3(() => {
1130
+ if (!pending) return;
1131
+ void onRecorded(pending.file);
1132
+ discard();
1133
+ }, [discard, onRecorded, pending]);
1117
1134
  const stop = useCallback3(() => {
1118
1135
  recorderRef.current?.stop();
1119
1136
  }, []);
@@ -1137,9 +1154,18 @@ function AudioRecorderButton({
1137
1154
  onRecordingChange?.(false);
1138
1155
  recorderRef.current = null;
1139
1156
  const blob = new Blob(chunks, { type: format.uploadMimeType });
1140
- void onRecorded(
1141
- new File([blob], `audio-${Date.now()}.${format.extension}`, { type: format.uploadMimeType })
1142
- );
1157
+ const file = new File([blob], `audio-${Date.now()}.${format.extension}`, {
1158
+ type: format.uploadMimeType
1159
+ });
1160
+ if (blob.size === 0) {
1161
+ onFailure?.(labelOf("empty"));
1162
+ return;
1163
+ }
1164
+ if (!reviewBeforeSend) {
1165
+ void onRecorded(file);
1166
+ return;
1167
+ }
1168
+ setPending({ file, objectURL: URL.createObjectURL(blob) });
1143
1169
  });
1144
1170
  recorderRef.current = recorder;
1145
1171
  recorder.start();
@@ -1149,23 +1175,67 @@ function AudioRecorderButton({
1149
1175
  } catch {
1150
1176
  onFailure?.(labels?.denied ?? DEFAULT_AUDIO_RECORDER_BUTTON_LABELS.denied);
1151
1177
  }
1152
- }, [labels?.denied, labels?.unsupported, maxDurationMilliseconds, onFailure, onRecorded, onRecordingChange]);
1153
- return /* @__PURE__ */ jsx13(
1154
- "button",
1155
- {
1156
- type: "button",
1157
- disabled,
1158
- onClick: () => isRecording ? stop() : void start(),
1159
- title: isRecording ? stopLabel : startLabel,
1160
- "aria-label": isRecording ? stopLabel : startLabel,
1161
- "aria-pressed": isRecording,
1162
- className: `flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-full transition-colors ${isRecording ? "animate-pulse bg-red-500 text-white ring-4 ring-red-500/30 hover:bg-red-600" : "text-gray-500 hover:bg-gray-200 dark:hover:bg-gray-700"}`,
1163
- children: isRecording ? /* @__PURE__ */ jsx13("svg", { width: "18", height: "18", viewBox: "0 0 24 24", fill: "currentColor", children: /* @__PURE__ */ jsx13("rect", { x: "6", y: "6", width: "12", height: "12", rx: "2" }) }) : /* @__PURE__ */ jsxs9("svg", { width: "20", height: "20", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", children: [
1164
- /* @__PURE__ */ jsx13("path", { d: "M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3z" }),
1165
- /* @__PURE__ */ jsx13("path", { d: "M19 11a7 7 0 0 1-14 0" }),
1166
- /* @__PURE__ */ jsx13("line", { x1: "12", y1: "18", x2: "12", y2: "22" })
1167
- ] })
1168
- }
1178
+ }, [maxDurationMilliseconds, onFailure, onRecorded, onRecordingChange, reviewBeforeSend]);
1179
+ const toggleLabel = isRecording ? labelOf("stop") : labelOf("start");
1180
+ return (
1181
+ /* O painel de revisão flutua sobre o botão em vez de ocupar espaço na barra: o microfone mora
1182
+ na caixa do botão de enviar, e empurrar o composer para cima a cada gravação faria a
1183
+ conversa saltar. */
1184
+ /* @__PURE__ */ jsxs9("div", { className: "relative flex-shrink-0", children: [
1185
+ pending && /* @__PURE__ */ jsxs9(
1186
+ "div",
1187
+ {
1188
+ role: "group",
1189
+ "aria-label": labelOf("review"),
1190
+ className: "absolute bottom-full right-0 z-20 mb-2 flex w-64 items-center gap-2 rounded-xl border border-gray-200 bg-white p-2 shadow-lg dark:border-gray-700 dark:bg-gray-800",
1191
+ children: [
1192
+ /* @__PURE__ */ jsx13("audio", { src: pending.objectURL, controls: true, className: "h-8 min-w-0 flex-1" }),
1193
+ /* @__PURE__ */ jsx13(
1194
+ "button",
1195
+ {
1196
+ type: "button",
1197
+ onClick: discard,
1198
+ title: labelOf("discard"),
1199
+ "aria-label": labelOf("discard"),
1200
+ className: "flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full text-gray-500 transition-colors hover:bg-gray-100 hover:text-red-500 dark:hover:bg-gray-700",
1201
+ children: /* @__PURE__ */ jsxs9("svg", { width: "16", height: "16", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", children: [
1202
+ /* @__PURE__ */ jsx13("line", { x1: "18", y1: "6", x2: "6", y2: "18" }),
1203
+ /* @__PURE__ */ jsx13("line", { x1: "6", y1: "6", x2: "18", y2: "18" })
1204
+ ] })
1205
+ }
1206
+ ),
1207
+ /* @__PURE__ */ jsx13(
1208
+ "button",
1209
+ {
1210
+ type: "button",
1211
+ onClick: confirm,
1212
+ title: labelOf("send"),
1213
+ "aria-label": labelOf("send"),
1214
+ className: "flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full bg-emerald-500 text-white transition-colors hover:bg-emerald-600",
1215
+ children: /* @__PURE__ */ jsx13("svg", { width: "16", height: "16", viewBox: "0 0 24 24", fill: "currentColor", children: /* @__PURE__ */ jsx13("path", { d: "M2.01 21L23 12 2.01 3 2 10l15 2-15 2z" }) })
1216
+ }
1217
+ )
1218
+ ]
1219
+ }
1220
+ ),
1221
+ /* @__PURE__ */ jsx13(
1222
+ "button",
1223
+ {
1224
+ type: "button",
1225
+ disabled: disabled || pending !== void 0,
1226
+ onClick: () => isRecording ? stop() : void start(),
1227
+ title: toggleLabel,
1228
+ "aria-label": toggleLabel,
1229
+ "aria-pressed": isRecording,
1230
+ className: `flex h-10 w-10 items-center justify-center rounded-full transition-colors disabled:opacity-50 ${isRecording ? "animate-pulse bg-red-500 text-white ring-4 ring-red-500/30 hover:bg-red-600" : "text-gray-500 hover:bg-gray-200 dark:hover:bg-gray-700"}`,
1231
+ children: isRecording ? /* @__PURE__ */ jsx13("svg", { width: "18", height: "18", viewBox: "0 0 24 24", fill: "currentColor", children: /* @__PURE__ */ jsx13("rect", { x: "6", y: "6", width: "12", height: "12", rx: "2" }) }) : /* @__PURE__ */ jsxs9("svg", { width: "20", height: "20", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", children: [
1232
+ /* @__PURE__ */ jsx13("path", { d: "M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3z" }),
1233
+ /* @__PURE__ */ jsx13("path", { d: "M19 11a7 7 0 0 1-14 0" }),
1234
+ /* @__PURE__ */ jsx13("line", { x1: "12", y1: "18", x2: "12", y2: "22" })
1235
+ ] })
1236
+ }
1237
+ )
1238
+ ] })
1169
1239
  );
1170
1240
  }
1171
1241
 
@@ -1240,7 +1310,7 @@ function phoneInitials(number) {
1240
1310
  }
1241
1311
 
1242
1312
  // src/hooks/useAsyncResource.ts
1243
- import { useCallback as useCallback4, useEffect as useEffect2, useRef as useRef4, useState as useState8 } from "react";
1313
+ import { useCallback as useCallback4, useEffect as useEffect3, useRef as useRef4, useState as useState8 } from "react";
1244
1314
  function useAsyncResource(fetcher, deps) {
1245
1315
  const [data, setData] = useState8(void 0);
1246
1316
  const [loading, setLoading] = useState8(false);
@@ -1259,7 +1329,7 @@ function useAsyncResource(fetcher, deps) {
1259
1329
  if (requestId === requestIdRef.current) setLoading(false);
1260
1330
  }
1261
1331
  }, deps);
1262
- useEffect2(() => {
1332
+ useEffect3(() => {
1263
1333
  load();
1264
1334
  }, [load]);
1265
1335
  return { data, loading, error, refetch: load };
@@ -1588,7 +1658,7 @@ function ConversationDocumentsPanel({
1588
1658
  }
1589
1659
 
1590
1660
  // src/DocumentsLibrary.tsx
1591
- import { useEffect as useEffect3, useState as useState10 } from "react";
1661
+ import { useEffect as useEffect4, useState as useState10 } from "react";
1592
1662
  import { ArrowUpDown as ArrowUpDown2, Bot as Bot2, Download as Download2, Eye as Eye2, MessageSquare, Users as Users2 } from "lucide-react";
1593
1663
  import { jsx as jsx16, jsxs as jsxs11 } from "react/jsx-runtime";
1594
1664
  var DEFAULT_DOCUMENTS_LIBRARY_LABELS = {
@@ -1632,7 +1702,7 @@ function DocumentsLibrary({
1632
1702
  const hasFilters = search !== "" || sourceFilter !== DOCUMENT_SOURCE_FILTER.ALL || sortDirection !== "desc";
1633
1703
  const lastPage = Math.max(1, Math.ceil(total / perPage));
1634
1704
  const fetchAll = context?.api.getAllDocuments;
1635
- useEffect3(() => {
1705
+ useEffect4(() => {
1636
1706
  if (!fetchAll) return;
1637
1707
  let active = true;
1638
1708
  setLoading(true);
package/dist/index.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import * as react from 'react';
2
2
  import react__default, { ReactNode, CSSProperties, FormEvent } from 'react';
3
- import { G as MessagePayload, K as ResolveMediaUrl, z as InteractiveSelection, x as InteractivePayload, r as ConversationsFeatures, o as ConversationSummary, j as ConversationChannel, L as ListConversationsParams, q as ConversationsApi, S as SSEProvider, B as ListDocumentsParams, k as ConversationDocument, p as ConversationTemplate } from './types-C_dqJ83O.js';
4
- export { A as AudioRecorderButton, a as AudioRecorderButtonLabels, b as AudioRecorderButtonProps, C as CHANNEL_CAPABILITIES, c as CHANNEL_FILTER_ALL, d as CONVERSATION_CHANNEL, e as ChannelCapabilities, f as ChannelFilter, g as ChannelFilterOption, h as CompanyDocument, i as CompanyDocumentPage, l as ConversationDocumentPage, m as ConversationEventSource, n as ConversationPage, s as ConversationsTheme, t as ConversationsUIConfig, D as DEFAULT_AUDIO_RECORDER_BUTTON_LABELS, u as DEFAULT_CONVERSATION_CHANNEL, v as DEFAULT_MAX_RECORDING_MILLISECONDS, F as FormatContactHandleParams, H as HANDLE_KIND, w as HandleKind, I as InteractiveOption, y as InteractiveSection, M as MediaRenderer, E as MediaRendererProps, R as REOPEN_MECHANISM, J as ReopenMechanism, N as capabilitiesOf, O as channelFiltersFor, P as contactFlag, Q as formatContactHandle } from './types-C_dqJ83O.js';
3
+ import { G as MessagePayload, K as ResolveMediaUrl, z as InteractiveSelection, x as InteractivePayload, r as ConversationsFeatures, o as ConversationSummary, j as ConversationChannel, L as ListConversationsParams, q as ConversationsApi, S as SSEProvider, B as ListDocumentsParams, k as ConversationDocument, p as ConversationTemplate } from './types-O7kMP1Yn.js';
4
+ export { A as AudioRecorderButton, a as AudioRecorderButtonLabels, b as AudioRecorderButtonProps, C as CHANNEL_CAPABILITIES, c as CHANNEL_FILTER_ALL, d as CONVERSATION_CHANNEL, e as ChannelCapabilities, f as ChannelFilter, g as ChannelFilterOption, h as CompanyDocument, i as CompanyDocumentPage, l as ConversationDocumentPage, m as ConversationEventSource, n as ConversationPage, s as ConversationsTheme, t as ConversationsUIConfig, D as DEFAULT_AUDIO_RECORDER_BUTTON_LABELS, u as DEFAULT_CONVERSATION_CHANNEL, v as DEFAULT_MAX_RECORDING_MILLISECONDS, F as FormatContactHandleParams, H as HANDLE_KIND, w as HandleKind, I as InteractiveOption, y as InteractiveSection, M as MediaRenderer, E as MediaRendererProps, R as REOPEN_MECHANISM, J as ReopenMechanism, N as capabilitiesOf, O as channelFiltersFor, P as contactFlag, Q as formatContactHandle } from './types-O7kMP1Yn.js';
5
5
 
6
6
  interface MessageBubbleProps {
7
7
  message: MessagePayload;
package/dist/index.js CHANGED
@@ -44,7 +44,7 @@ import {
44
44
  useConversationDocuments,
45
45
  useConversationLocales,
46
46
  useConversations
47
- } from "./chunk-QFEESERN.js";
47
+ } from "./chunk-SOWV4264.js";
48
48
  import {
49
49
  htmlToWA,
50
50
  parseWhatsAppFormatting,
@@ -319,15 +319,16 @@ var RichMessageComposer = forwardRef(function RichMessageComposer2({
319
319
  return /* @__PURE__ */ jsxs2("div", { className: cn("flex flex-col", className), children: [
320
320
  attachmentsPreview,
321
321
  quickReplies?.length ? (
322
- /* Uma linha rolável no celular, quebrando em várias no desktop: empilhar os chips no
323
- celular come a altura da conversa, que é o que o operador precisa ver. */
324
- /* @__PURE__ */ jsx2("div", { className: "mb-2 flex flex-nowrap gap-1 overflow-x-auto sm:flex-wrap sm:overflow-x-visible [scrollbar-width:none] [&::-webkit-scrollbar]:hidden", children: quickReplies.map((quickReply) => /* @__PURE__ */ jsx2(
322
+ /* Uma linha só, rolando na horizontal. Deixar quebrar em várias linhas faz a barra crescer
323
+ conforme o número de respostas rápidas e empurrar a conversa para cima o composer
324
+ precisa ter altura previsível, independente de quantos atalhos o host configurou. */
325
+ /* @__PURE__ */ jsx2("div", { className: "mb-2 flex flex-nowrap gap-1 overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden", children: quickReplies.map((quickReply) => /* @__PURE__ */ jsx2(
325
326
  "button",
326
327
  {
327
328
  type: "button",
328
329
  title: quickReply.tooltip,
329
330
  onClick: () => replaceContent(quickReply.text),
330
- className: "whitespace-nowrap rounded-full border border-gray-200 bg-white px-2 py-1 text-xs text-gray-600 transition-colors hover:border-teal-200 hover:bg-teal-50 hover:text-teal-700 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-400 dark:hover:border-teal-800 dark:hover:bg-teal-950/40 dark:hover:text-teal-400",
331
+ className: "flex-shrink-0 whitespace-nowrap rounded-full border border-gray-200 bg-white px-2 py-1 text-xs text-gray-600 transition-colors hover:border-teal-200 hover:bg-teal-50 hover:text-teal-700 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-400 dark:hover:border-teal-800 dark:hover:bg-teal-950/40 dark:hover:text-teal-400",
331
332
  children: quickReply.label
332
333
  },
333
334
  quickReply.id
@@ -1,5 +1,5 @@
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-C_dqJ83O.js';
2
- export { A as AudioRecorderButton, a as AudioRecorderButtonLabels, b as AudioRecorderButtonProps, D as DEFAULT_AUDIO_RECORDER_BUTTON_LABELS } from '../types-C_dqJ83O.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, 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';
3
3
  import * as react from 'react';
4
4
  import { InteractiveReplyOption, InboundMediaType } from '@adatechnology/meta-whatsapp-contracts/testing';
5
5
 
@@ -8,7 +8,7 @@ import {
8
8
  DocumentsLibrary,
9
9
  MessageBubble,
10
10
  MessageComposer
11
- } from "../chunk-QFEESERN.js";
11
+ } from "../chunk-SOWV4264.js";
12
12
  import "../chunk-2AYDBWNE.js";
13
13
 
14
14
  // src/preview/previewStore.ts
@@ -1019,7 +1019,7 @@ function ConversationPreview({
1019
1019
  {
1020
1020
  onSend: (text) => void handleSend(text),
1021
1021
  onAttach: uploadMedia ? (file) => void handleAttach(file) : void 0,
1022
- placeholder: isRecording ? "Gravando\u2026 toque no quadrado para enviar" : placeholder ?? "Escreva como o cliente\u2026",
1022
+ placeholder: isRecording ? "Gravando\u2026 toque no quadrado para ouvir" : placeholder ?? "Escreva como o cliente\u2026",
1023
1023
  idleAction: uploadMedia ? /* @__PURE__ */ jsx(
1024
1024
  AudioRecorderButton,
1025
1025
  {
@@ -118,6 +118,10 @@ interface AudioRecorderButtonLabels {
118
118
  stop: string;
119
119
  unsupported: string;
120
120
  denied: string;
121
+ review: string;
122
+ send: string;
123
+ discard: string;
124
+ empty: string;
121
125
  }
122
126
  declare const DEFAULT_AUDIO_RECORDER_BUTTON_LABELS: AudioRecorderButtonLabels;
123
127
  interface AudioRecorderButtonProps {
@@ -129,6 +133,12 @@ interface AudioRecorderButtonProps {
129
133
  * que o microfone está quebrado.
130
134
  */
131
135
  onRecordingChange?: (isRecording: boolean) => void;
136
+ /**
137
+ * Abre uma etapa de revisão quando a gravação para: o áudio toca ali mesmo e só sai depois de
138
+ * confirmado. Ligado por padrão — voz é o único anexo que quem envia não viu antes de mandar, e
139
+ * sem ouvir não há como saber se o microfone captou alguma coisa. Desligar volta ao envio direto.
140
+ */
141
+ reviewBeforeSend?: boolean;
132
142
  /**
133
143
  * Teto de duração da gravação, em milissegundos. Passado o tempo, o gravador para e envia o que
134
144
  * tem. Produto com limite próprio sobrescreve.
@@ -143,7 +153,7 @@ interface AudioRecorderButtonProps {
143
153
  * existe porque gravação esquecida aberta só se descobre no envio, com o arquivo inteiro perdido.
144
154
  */
145
155
  declare const DEFAULT_MAX_RECORDING_MILLISECONDS: number;
146
- declare function AudioRecorderButton({ onRecorded, onFailure, onRecordingChange, maxDurationMilliseconds, labels, disabled, }: AudioRecorderButtonProps): react.JSX.Element;
156
+ declare function AudioRecorderButton({ onRecorded, onFailure, onRecordingChange, reviewBeforeSend, maxDurationMilliseconds, labels, disabled, }: AudioRecorderButtonProps): react.JSX.Element;
147
157
 
148
158
  /**
149
159
  * Canal de origem da conversa e o que cada um permite.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adatechnology/conversations-ui",
3
- "version": "0.1.0-rc.13",
3
+ "version": "0.1.0-rc.15",
4
4
  "description": "WhatsApp conversation UI components — parametrizável por endpoint, tema e feature flags",
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -0,0 +1,30 @@
1
+ import { describe, expect, it } from 'bun:test'
2
+ import { renderToStaticMarkup } from 'react-dom/server'
3
+
4
+ import { AudioRecorderButton, DEFAULT_AUDIO_RECORDER_BUTTON_LABELS } from './AudioRecorderButton'
5
+
6
+ const noop = () => {}
7
+
8
+ describe('AudioRecorderButton', () => {
9
+ it('começa oferecendo gravar, sem painel de revisão à vista', () => {
10
+ const markup = renderToStaticMarkup(<AudioRecorderButton onRecorded={noop} />)
11
+
12
+ expect(markup).toContain(`title="${DEFAULT_AUDIO_RECORDER_BUTTON_LABELS.start}"`)
13
+ expect(markup).not.toContain(`title="${DEFAULT_AUDIO_RECORDER_BUTTON_LABELS.send}"`)
14
+ expect(markup).not.toContain('<audio')
15
+ })
16
+
17
+ it('deixa o host trocar o texto de um rótulo sem perder os outros', () => {
18
+ const markup = renderToStaticMarkup(
19
+ <AudioRecorderButton onRecorded={noop} labels={{ start: 'Gravar recado' }} />,
20
+ )
21
+
22
+ expect(markup).toContain('title="Gravar recado"')
23
+ })
24
+
25
+ it('mantém a caixa de 40px do botão de enviar para a barra não pular', () => {
26
+ const markup = renderToStaticMarkup(<AudioRecorderButton onRecorded={noop} />)
27
+
28
+ expect(markup).toContain('h-10 w-10')
29
+ })
30
+ })
@@ -8,13 +8,17 @@
8
8
  * hospeda e devolve o `mediaId` é o host, via `uploadMedia`.
9
9
  */
10
10
 
11
- import { useCallback, useRef, useState } from 'react'
11
+ import { useCallback, useEffect, useRef, useState } from 'react'
12
12
 
13
13
  export interface AudioRecorderButtonLabels {
14
14
  start: string
15
15
  stop: string
16
16
  unsupported: string
17
17
  denied: string
18
+ review: string
19
+ send: string
20
+ discard: string
21
+ empty: string
18
22
  }
19
23
 
20
24
  export const DEFAULT_AUDIO_RECORDER_BUTTON_LABELS: AudioRecorderButtonLabels = {
@@ -22,6 +26,10 @@ export const DEFAULT_AUDIO_RECORDER_BUTTON_LABELS: AudioRecorderButtonLabels = {
22
26
  stop: 'Parar gravação',
23
27
  unsupported: 'Este navegador não grava áudio.',
24
28
  denied: 'Sem permissão para usar o microfone.',
29
+ review: 'Ouça antes de enviar',
30
+ send: 'Enviar áudio',
31
+ discard: 'Descartar áudio',
32
+ empty: 'Nada foi captado pelo microfone.',
25
33
  }
26
34
 
27
35
  export interface AudioRecorderButtonProps {
@@ -33,6 +41,12 @@ export interface AudioRecorderButtonProps {
33
41
  * que o microfone está quebrado.
34
42
  */
35
43
  onRecordingChange?: (isRecording: boolean) => void
44
+ /**
45
+ * Abre uma etapa de revisão quando a gravação para: o áudio toca ali mesmo e só sai depois de
46
+ * confirmado. Ligado por padrão — voz é o único anexo que quem envia não viu antes de mandar, e
47
+ * sem ouvir não há como saber se o microfone captou alguma coisa. Desligar volta ao envio direto.
48
+ */
49
+ reviewBeforeSend?: boolean
36
50
  /**
37
51
  * Teto de duração da gravação, em milissegundos. Passado o tempo, o gravador para e envia o que
38
52
  * tem. Produto com limite próprio sobrescreve.
@@ -70,20 +84,41 @@ export function resolveRecordingFormat(): RecordingFormat | undefined {
70
84
  return RECORDING_FORMATS.find((format) => MediaRecorder.isTypeSupported(format.mimeType))
71
85
  }
72
86
 
87
+ type PendingRecording = { file: File; objectURL: string }
88
+
73
89
  export function AudioRecorderButton({
74
90
  onRecorded,
75
91
  onFailure,
76
92
  onRecordingChange,
93
+ reviewBeforeSend = true,
77
94
  maxDurationMilliseconds = DEFAULT_MAX_RECORDING_MILLISECONDS,
78
95
  labels,
79
96
  disabled,
80
97
  }: AudioRecorderButtonProps) {
81
- const startLabel = labels?.start ?? DEFAULT_AUDIO_RECORDER_BUTTON_LABELS.start
82
- const stopLabel = labels?.stop ?? DEFAULT_AUDIO_RECORDER_BUTTON_LABELS.stop
98
+ const labelOf = (key: keyof AudioRecorderButtonLabels): string =>
99
+ labels?.[key] ?? DEFAULT_AUDIO_RECORDER_BUTTON_LABELS[key]
83
100
  const [isRecording, setIsRecording] = useState(false)
101
+ const [pending, setPending] = useState<PendingRecording | undefined>(undefined)
84
102
  const recorderRef = useRef<MediaRecorder | null>(null)
85
103
  const autoStopRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
86
104
 
105
+ // O `objectURL` é um recurso do documento, não do React: sem revogar, cada gravação descartada
106
+ // deixa o blob inteiro preso na memória da aba até um reload.
107
+ const discard = useCallback(() => {
108
+ setPending((current) => {
109
+ if (current) URL.revokeObjectURL(current.objectURL)
110
+ return undefined
111
+ })
112
+ }, [])
113
+
114
+ useEffect(() => discard, [discard])
115
+
116
+ const confirm = useCallback(() => {
117
+ if (!pending) return
118
+ void onRecorded(pending.file)
119
+ discard()
120
+ }, [discard, onRecorded, pending])
121
+
87
122
  const stop = useCallback(() => {
88
123
  recorderRef.current?.stop()
89
124
  }, [])
@@ -114,9 +149,23 @@ export function AudioRecorderButton({
114
149
  // O `File` sai com o MIME sem os parâmetros de codec: `audio/ogg;codecs=opus` serve ao
115
150
  // gravador, mas quem valida upload compara com `audio/ogg` puro.
116
151
  const blob = new Blob(chunks, { type: format.uploadMimeType })
117
- void onRecorded(
118
- new File([blob], `audio-${Date.now()}.${format.extension}`, { type: format.uploadMimeType }),
119
- )
152
+ const file = new File([blob], `audio-${Date.now()}.${format.extension}`, {
153
+ type: format.uploadMimeType,
154
+ })
155
+
156
+ // Gravação vazia não vira anexo: microfone mudo ou permissão revogada no meio produzem um
157
+ // blob de zero byte, e mandá-lo adiante só falha lá na frente, sem dizer por quê.
158
+ if (blob.size === 0) {
159
+ onFailure?.(labelOf('empty'))
160
+ return
161
+ }
162
+
163
+ if (!reviewBeforeSend) {
164
+ void onRecorded(file)
165
+ return
166
+ }
167
+
168
+ setPending({ file, objectURL: URL.createObjectURL(blob) })
120
169
  })
121
170
 
122
171
  recorderRef.current = recorder
@@ -127,29 +176,66 @@ export function AudioRecorderButton({
127
176
  } catch {
128
177
  onFailure?.(labels?.denied ?? DEFAULT_AUDIO_RECORDER_BUTTON_LABELS.denied)
129
178
  }
130
- }, [labels?.denied, labels?.unsupported, maxDurationMilliseconds, onFailure, onRecorded, onRecordingChange])
179
+ // `labelOf` lê `labels` a cada render e não entra aqui; o que importa para recriar o gravador
180
+ // são o teto de duração, o destino da gravação e se há etapa de revisão.
181
+ // eslint-disable-next-line react-hooks/exhaustive-deps
182
+ }, [maxDurationMilliseconds, onFailure, onRecorded, onRecordingChange, reviewBeforeSend])
183
+
184
+ const toggleLabel = isRecording ? labelOf('stop') : labelOf('start')
131
185
 
132
186
  return (
133
- <button
134
- type="button"
135
- disabled={disabled}
136
- onClick={() => (isRecording ? stop() : void start())}
137
- title={isRecording ? stopLabel : startLabel}
138
- aria-label={isRecording ? stopLabel : startLabel}
139
- aria-pressed={isRecording}
140
- /* Mesma caixa do botão de enviar: o microfone ocupa o lugar dele enquanto o campo está
141
- vazio, e qualquer diferença de tamanho faz a barra pular a cada letra digitada. */
142
- className={`flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-full transition-colors ${
143
- isRecording
144
- ? 'animate-pulse bg-red-500 text-white ring-4 ring-red-500/30 hover:bg-red-600'
145
- : 'text-gray-500 hover:bg-gray-200 dark:hover:bg-gray-700'
146
- }`}
147
- >
148
- {isRecording ? (
149
- <svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor"><rect x="6" y="6" width="12" height="12" rx="2" /></svg>
150
- ) : (
151
- <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"><path d="M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3z" /><path d="M19 11a7 7 0 0 1-14 0" /><line x1="12" y1="18" x2="12" y2="22" /></svg>
187
+ /* O painel de revisão flutua sobre o botão em vez de ocupar espaço na barra: o microfone mora
188
+ na caixa do botão de enviar, e empurrar o composer para cima a cada gravação faria a
189
+ conversa saltar. */
190
+ <div className="relative flex-shrink-0">
191
+ {pending && (
192
+ <div
193
+ role="group"
194
+ aria-label={labelOf('review')}
195
+ className="absolute bottom-full right-0 z-20 mb-2 flex w-64 items-center gap-2 rounded-xl border border-gray-200 bg-white p-2 shadow-lg dark:border-gray-700 dark:bg-gray-800"
196
+ >
197
+ <audio src={pending.objectURL} controls className="h-8 min-w-0 flex-1" />
198
+ <button
199
+ type="button"
200
+ onClick={discard}
201
+ title={labelOf('discard')}
202
+ aria-label={labelOf('discard')}
203
+ className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full text-gray-500 transition-colors hover:bg-gray-100 hover:text-red-500 dark:hover:bg-gray-700"
204
+ >
205
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"><line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" /></svg>
206
+ </button>
207
+ <button
208
+ type="button"
209
+ onClick={confirm}
210
+ title={labelOf('send')}
211
+ aria-label={labelOf('send')}
212
+ className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full bg-emerald-500 text-white transition-colors hover:bg-emerald-600"
213
+ >
214
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><path d="M2.01 21L23 12 2.01 3 2 10l15 2-15 2z" /></svg>
215
+ </button>
216
+ </div>
152
217
  )}
153
- </button>
218
+ <button
219
+ type="button"
220
+ disabled={disabled || pending !== undefined}
221
+ onClick={() => (isRecording ? stop() : void start())}
222
+ title={toggleLabel}
223
+ aria-label={toggleLabel}
224
+ aria-pressed={isRecording}
225
+ /* Mesma caixa do botão de enviar: o microfone ocupa o lugar dele enquanto o campo está
226
+ vazio, e qualquer diferença de tamanho faz a barra pular a cada letra digitada. */
227
+ className={`flex h-10 w-10 items-center justify-center rounded-full transition-colors disabled:opacity-50 ${
228
+ isRecording
229
+ ? 'animate-pulse bg-red-500 text-white ring-4 ring-red-500/30 hover:bg-red-600'
230
+ : 'text-gray-500 hover:bg-gray-200 dark:hover:bg-gray-700'
231
+ }`}
232
+ >
233
+ {isRecording ? (
234
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor"><rect x="6" y="6" width="12" height="12" rx="2" /></svg>
235
+ ) : (
236
+ <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"><path d="M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3z" /><path d="M19 11a7 7 0 0 1-14 0" /><line x1="12" y1="18" x2="12" y2="22" /></svg>
237
+ )}
238
+ </button>
239
+ </div>
154
240
  )
155
241
  }
@@ -246,16 +246,17 @@ export const RichMessageComposer = forwardRef<RichMessageComposerHandle, RichMes
246
246
  {attachmentsPreview}
247
247
 
248
248
  {quickReplies?.length ? (
249
- /* Uma linha rolável no celular, quebrando em várias no desktop: empilhar os chips no
250
- celular come a altura da conversa, que é o que o operador precisa ver. */
251
- <div className="mb-2 flex flex-nowrap gap-1 overflow-x-auto sm:flex-wrap sm:overflow-x-visible [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
249
+ /* Uma linha só, rolando na horizontal. Deixar quebrar em várias linhas faz a barra crescer
250
+ conforme o número de respostas rápidas e empurrar a conversa para cima o composer
251
+ precisa ter altura previsível, independente de quantos atalhos o host configurou. */
252
+ <div className="mb-2 flex flex-nowrap gap-1 overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
252
253
  {quickReplies.map((quickReply) => (
253
254
  <button
254
255
  key={quickReply.id}
255
256
  type="button"
256
257
  title={quickReply.tooltip}
257
258
  onClick={() => replaceContent(quickReply.text)}
258
- className="whitespace-nowrap rounded-full border border-gray-200 bg-white px-2 py-1 text-xs text-gray-600 transition-colors hover:border-teal-200 hover:bg-teal-50 hover:text-teal-700 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-400 dark:hover:border-teal-800 dark:hover:bg-teal-950/40 dark:hover:text-teal-400"
259
+ className="flex-shrink-0 whitespace-nowrap rounded-full border border-gray-200 bg-white px-2 py-1 text-xs text-gray-600 transition-colors hover:border-teal-200 hover:bg-teal-50 hover:text-teal-700 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-400 dark:hover:border-teal-800 dark:hover:bg-teal-950/40 dark:hover:text-teal-400"
259
260
  >
260
261
  {quickReply.label}
261
262
  </button>
@@ -305,7 +305,7 @@ export function ConversationPreview({
305
305
  que envia — sem esse aviso o operador grava, não vê nada acontecer e conclui que o
306
306
  microfone está quebrado. */
307
307
  placeholder={
308
- isRecording ? 'Gravando… toque no quadrado para enviar' : (placeholder ?? 'Escreva como o cliente…')
308
+ isRecording ? 'Gravando… toque no quadrado para ouvir' : (placeholder ?? 'Escreva como o cliente…')
309
309
  }
310
310
  idleAction={
311
311
  uploadMedia ? (