@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.
- package/dist/{chunk-QFEESERN.js → chunk-SOWV4264.js} +98 -28
- package/dist/index.d.ts +2 -2
- package/dist/index.js +6 -5
- package/dist/preview/index.d.ts +2 -2
- package/dist/preview/index.js +2 -2
- package/dist/{types-C_dqJ83O.d.ts → types-O7kMP1Yn.d.ts} +11 -1
- package/package.json +1 -1
- package/src/AudioRecorderButton.test.tsx +30 -0
- package/src/AudioRecorderButton.tsx +113 -27
- package/src/RichMessageComposer.tsx +5 -4
- package/src/preview/ConversationPreview.tsx +1 -1
|
@@ -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
|
|
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
|
-
|
|
1141
|
-
|
|
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
|
-
}, [
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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-
|
|
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-
|
|
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-
|
|
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
|
|
323
|
-
|
|
324
|
-
|
|
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
|
package/dist/preview/index.d.ts
CHANGED
|
@@ -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-
|
|
2
|
-
export { A as AudioRecorderButton, a as AudioRecorderButtonLabels, b as AudioRecorderButtonProps, D as DEFAULT_AUDIO_RECORDER_BUTTON_LABELS } from '../types-
|
|
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
|
|
package/dist/preview/index.js
CHANGED
|
@@ -8,7 +8,7 @@ import {
|
|
|
8
8
|
DocumentsLibrary,
|
|
9
9
|
MessageBubble,
|
|
10
10
|
MessageComposer
|
|
11
|
-
} from "../chunk-
|
|
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
|
|
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
|
@@ -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
|
|
82
|
-
|
|
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
|
-
|
|
118
|
-
|
|
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
|
-
|
|
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
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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
|
-
|
|
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
|
|
250
|
-
|
|
251
|
-
|
|
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
|
|
308
|
+
isRecording ? 'Gravando… toque no quadrado para ouvir' : (placeholder ?? 'Escreva como o cliente…')
|
|
309
309
|
}
|
|
310
310
|
idleAction={
|
|
311
311
|
uploadMedia ? (
|