@adatechnology/conversations-ui 0.1.0-rc.10 → 0.1.0-rc.12
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-FWNAWAJP.js → chunk-3RKFU46R.js} +35 -0
- package/dist/index.d.ts +103 -2
- package/dist/index.js +694 -435
- package/dist/preview/index.d.ts +12 -1
- package/dist/preview/index.js +21 -6
- package/package.json +1 -1
- package/src/MessageComposer.tsx +73 -0
- package/src/RichMessageComposer.test.tsx +83 -0
- package/src/RichMessageComposer.tsx +379 -0
- package/src/index.ts +11 -0
- package/src/preview/AudioRecorderButton.tsx +33 -3
- package/src/preview/ConversationPreview.tsx +8 -1
- package/src/quickReply.test.ts +58 -0
package/dist/preview/index.d.ts
CHANGED
|
@@ -224,10 +224,21 @@ declare const DEFAULT_AUDIO_RECORDER_BUTTON_LABELS: AudioRecorderButtonLabels;
|
|
|
224
224
|
interface AudioRecorderButtonProps {
|
|
225
225
|
onRecorded: (file: File) => void | Promise<void>;
|
|
226
226
|
onFailure?: (message: string) => void;
|
|
227
|
+
/**
|
|
228
|
+
* Avisa quando a gravação começa e termina. O botão é um interruptor — o segundo toque é que
|
|
229
|
+
* envia — e sem um aviso fora dele o operador grava, não vê nada acontecer e desiste achando
|
|
230
|
+
* que o microfone está quebrado.
|
|
231
|
+
*/
|
|
232
|
+
onRecordingChange?: (isRecording: boolean) => void;
|
|
233
|
+
/**
|
|
234
|
+
* Teto de duração da gravação, em milissegundos. Passado o tempo, o gravador para e envia o que
|
|
235
|
+
* tem. Produto com limite próprio sobrescreve.
|
|
236
|
+
*/
|
|
237
|
+
maxDurationMilliseconds?: number;
|
|
227
238
|
labels?: Partial<AudioRecorderButtonLabels>;
|
|
228
239
|
disabled?: boolean;
|
|
229
240
|
}
|
|
230
|
-
declare function AudioRecorderButton({ onRecorded, onFailure, labels, disabled }: AudioRecorderButtonProps): react.JSX.Element;
|
|
241
|
+
declare function AudioRecorderButton({ onRecorded, onFailure, onRecordingChange, maxDurationMilliseconds, labels, disabled, }: AudioRecorderButtonProps): react.JSX.Element;
|
|
231
242
|
|
|
232
243
|
/**
|
|
233
244
|
* Roteiro que mantém o preview vivo: sem tráfego chegando, a inbox é uma tela estática e as
|
package/dist/preview/index.js
CHANGED
|
@@ -6,7 +6,7 @@ import {
|
|
|
6
6
|
DocumentsLibrary,
|
|
7
7
|
MessageBubble,
|
|
8
8
|
MessageComposer
|
|
9
|
-
} from "../chunk-
|
|
9
|
+
} from "../chunk-3RKFU46R.js";
|
|
10
10
|
import "../chunk-2AYDBWNE.js";
|
|
11
11
|
|
|
12
12
|
// src/preview/previewStore.ts
|
|
@@ -856,6 +856,7 @@ var DEFAULT_AUDIO_RECORDER_BUTTON_LABELS = {
|
|
|
856
856
|
unsupported: "Este navegador n\xE3o grava \xE1udio.",
|
|
857
857
|
denied: "Sem permiss\xE3o para usar o microfone."
|
|
858
858
|
};
|
|
859
|
+
var DEFAULT_MAX_RECORDING_MILLISECONDS = 5 * 60 * 1e3;
|
|
859
860
|
var RECORDING_FORMATS = [
|
|
860
861
|
{ mimeType: "audio/ogg;codecs=opus", uploadMimeType: "audio/ogg", extension: "ogg" },
|
|
861
862
|
{ mimeType: "audio/mp4", uploadMimeType: "audio/mp4", extension: "m4a" },
|
|
@@ -866,11 +867,19 @@ function resolveRecordingFormat() {
|
|
|
866
867
|
if (typeof MediaRecorder.isTypeSupported !== "function") return RECORDING_FORMATS[0];
|
|
867
868
|
return RECORDING_FORMATS.find((format) => MediaRecorder.isTypeSupported(format.mimeType));
|
|
868
869
|
}
|
|
869
|
-
function AudioRecorderButton({
|
|
870
|
+
function AudioRecorderButton({
|
|
871
|
+
onRecorded,
|
|
872
|
+
onFailure,
|
|
873
|
+
onRecordingChange,
|
|
874
|
+
maxDurationMilliseconds = DEFAULT_MAX_RECORDING_MILLISECONDS,
|
|
875
|
+
labels,
|
|
876
|
+
disabled
|
|
877
|
+
}) {
|
|
870
878
|
const startLabel = labels?.start ?? DEFAULT_AUDIO_RECORDER_BUTTON_LABELS.start;
|
|
871
879
|
const stopLabel = labels?.stop ?? DEFAULT_AUDIO_RECORDER_BUTTON_LABELS.stop;
|
|
872
880
|
const [isRecording, setIsRecording] = useState(false);
|
|
873
881
|
const recorderRef = useRef(null);
|
|
882
|
+
const autoStopRef = useRef(void 0);
|
|
874
883
|
const stop = useCallback(() => {
|
|
875
884
|
recorderRef.current?.stop();
|
|
876
885
|
}, []);
|
|
@@ -889,7 +898,9 @@ function AudioRecorderButton({ onRecorded, onFailure, labels, disabled }) {
|
|
|
889
898
|
});
|
|
890
899
|
recorder.addEventListener("stop", () => {
|
|
891
900
|
stream.getTracks().forEach((track) => track.stop());
|
|
901
|
+
clearTimeout(autoStopRef.current);
|
|
892
902
|
setIsRecording(false);
|
|
903
|
+
onRecordingChange?.(false);
|
|
893
904
|
recorderRef.current = null;
|
|
894
905
|
const blob = new Blob(chunks, { type: format.uploadMimeType });
|
|
895
906
|
void onRecorded(
|
|
@@ -898,11 +909,13 @@ function AudioRecorderButton({ onRecorded, onFailure, labels, disabled }) {
|
|
|
898
909
|
});
|
|
899
910
|
recorderRef.current = recorder;
|
|
900
911
|
recorder.start();
|
|
912
|
+
autoStopRef.current = setTimeout(() => recorder.stop(), maxDurationMilliseconds);
|
|
901
913
|
setIsRecording(true);
|
|
914
|
+
onRecordingChange?.(true);
|
|
902
915
|
} catch {
|
|
903
916
|
onFailure?.(labels?.denied ?? DEFAULT_AUDIO_RECORDER_BUTTON_LABELS.denied);
|
|
904
917
|
}
|
|
905
|
-
}, [labels?.denied, labels?.unsupported, onFailure, onRecorded]);
|
|
918
|
+
}, [labels?.denied, labels?.unsupported, maxDurationMilliseconds, onFailure, onRecorded, onRecordingChange]);
|
|
906
919
|
return /* @__PURE__ */ jsx(
|
|
907
920
|
"button",
|
|
908
921
|
{
|
|
@@ -912,7 +925,7 @@ function AudioRecorderButton({ onRecorded, onFailure, labels, disabled }) {
|
|
|
912
925
|
title: isRecording ? stopLabel : startLabel,
|
|
913
926
|
"aria-label": isRecording ? stopLabel : startLabel,
|
|
914
927
|
"aria-pressed": isRecording,
|
|
915
|
-
className: `flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-full transition-colors ${isRecording ? "bg-red-500 text-white hover:bg-red-600" : "text-gray-500 hover:bg-gray-200 dark:hover:bg-gray-700"}`,
|
|
928
|
+
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"}`,
|
|
916
929
|
children: isRecording ? /* @__PURE__ */ jsx("svg", { width: "18", height: "18", viewBox: "0 0 24 24", fill: "currentColor", children: /* @__PURE__ */ jsx("rect", { x: "6", y: "6", width: "12", height: "12", rx: "2" }) }) : /* @__PURE__ */ jsxs("svg", { width: "20", height: "20", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", children: [
|
|
917
930
|
/* @__PURE__ */ jsx("path", { d: "M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3z" }),
|
|
918
931
|
/* @__PURE__ */ jsx("path", { d: "M19 11a7 7 0 0 1-14 0" }),
|
|
@@ -973,6 +986,7 @@ function ConversationPreview({
|
|
|
973
986
|
const [messages, setMessages] = useState2([]);
|
|
974
987
|
const [failure, setFailure] = useState2(void 0);
|
|
975
988
|
const [loadFailure, setLoadFailure] = useState2(void 0);
|
|
989
|
+
const [isRecording, setIsRecording] = useState2(false);
|
|
976
990
|
const [pendingLocal, setPendingLocal] = useState2([]);
|
|
977
991
|
const loadMessagesRef = useRef2(loadMessages);
|
|
978
992
|
const bottomRef = useRef2(null);
|
|
@@ -1093,12 +1107,13 @@ function ConversationPreview({
|
|
|
1093
1107
|
{
|
|
1094
1108
|
onSend: (text) => void handleSend(text),
|
|
1095
1109
|
onAttach: uploadMedia ? (file) => void handleAttach(file) : void 0,
|
|
1096
|
-
placeholder: placeholder ?? "Escreva como o cliente\u2026",
|
|
1110
|
+
placeholder: isRecording ? "Gravando\u2026 toque no quadrado para enviar" : placeholder ?? "Escreva como o cliente\u2026",
|
|
1097
1111
|
idleAction: uploadMedia ? /* @__PURE__ */ jsx2(
|
|
1098
1112
|
AudioRecorderButton,
|
|
1099
1113
|
{
|
|
1100
1114
|
onRecorded: (file) => void handleAttach(file),
|
|
1101
|
-
onFailure: (message) => setFailure(message)
|
|
1115
|
+
onFailure: (message) => setFailure(message),
|
|
1116
|
+
onRecordingChange: setIsRecording
|
|
1102
1117
|
}
|
|
1103
1118
|
) : void 0
|
|
1104
1119
|
}
|
package/package.json
CHANGED
package/src/MessageComposer.tsx
CHANGED
|
@@ -3,6 +3,40 @@ import type { ConversationsFeatures } from './types'
|
|
|
3
3
|
import { cn } from './lib/cn'
|
|
4
4
|
import { EmojiPicker } from './EmojiPicker'
|
|
5
5
|
|
|
6
|
+
/**
|
|
7
|
+
* Mensagem pronta que o atendente cola no campo com um clique.
|
|
8
|
+
*
|
|
9
|
+
* `text` aceita string com `{{variavel}}` ou função: a string cobre o caso comum (copy fixa com o
|
|
10
|
+
* nome do cliente no meio) sem o host escrever código, e a função cobre o que precisa de lógica —
|
|
11
|
+
* escolher texto por produto, pluralizar, formatar moeda.
|
|
12
|
+
*/
|
|
13
|
+
export interface QuickReply {
|
|
14
|
+
key: string
|
|
15
|
+
/** O que aparece no chip, emoji incluso: `👋 Saudação`. */
|
|
16
|
+
label: string
|
|
17
|
+
text: string | ((variables: Readonly<Record<string, string>>) => string)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Troca `{{nome}}` pelos valores passados. Variável ausente vira string vazia, e não o literal
|
|
22
|
+
* `{{nome}}`: mandar "Olá {{nome}}!" para o cliente é pior que mandar "Olá !".
|
|
23
|
+
*/
|
|
24
|
+
export function applyQuickReplyVariables(
|
|
25
|
+
template: string,
|
|
26
|
+
variables: Readonly<Record<string, string>> = {},
|
|
27
|
+
): string {
|
|
28
|
+
return template.replace(/\{\{\s*([\w.]+)\s*\}\}/g, (_, chave: string) => variables[chave] ?? '')
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function resolveQuickReply(
|
|
32
|
+
quickReply: QuickReply,
|
|
33
|
+
variables: Readonly<Record<string, string>> = {},
|
|
34
|
+
): string {
|
|
35
|
+
return typeof quickReply.text === 'function'
|
|
36
|
+
? quickReply.text(variables)
|
|
37
|
+
: applyQuickReplyVariables(quickReply.text, variables)
|
|
38
|
+
}
|
|
39
|
+
|
|
6
40
|
export interface MessageComposerLabels {
|
|
7
41
|
emoji: string
|
|
8
42
|
attach: string
|
|
@@ -31,12 +65,18 @@ export interface MessageComposerProps {
|
|
|
31
65
|
* microfone. Fora do campo, o botão vira um bloco solto ao lado do pill e quebra a barra.
|
|
32
66
|
*/
|
|
33
67
|
idleAction?: ReactNode
|
|
68
|
+
/** Mensagens prontas exibidas acima do campo. Vazio ou ausente, a faixa não é renderizada. */
|
|
69
|
+
quickReplies?: readonly QuickReply[]
|
|
70
|
+
/** Valores para `{{variavel}}` — tipicamente nome do cliente, produto, protocolo. */
|
|
71
|
+
quickReplyVariables?: Readonly<Record<string, string>>
|
|
34
72
|
className?: string
|
|
35
73
|
classNames?: Partial<MessageComposerClassNames>
|
|
36
74
|
}
|
|
37
75
|
|
|
38
76
|
export interface MessageComposerClassNames {
|
|
39
77
|
root: string
|
|
78
|
+
quickReplies: string
|
|
79
|
+
quickReply: string
|
|
40
80
|
field: string
|
|
41
81
|
}
|
|
42
82
|
|
|
@@ -67,6 +107,8 @@ export const MessageComposer = ({
|
|
|
67
107
|
onAttach,
|
|
68
108
|
value: externalValue,
|
|
69
109
|
onChange: externalOnChange,
|
|
110
|
+
quickReplies,
|
|
111
|
+
quickReplyVariables,
|
|
70
112
|
features,
|
|
71
113
|
placeholder = 'Digite uma mensagem...',
|
|
72
114
|
maxLength,
|
|
@@ -185,6 +227,37 @@ export const MessageComposer = ({
|
|
|
185
227
|
ordem do WhatsApp. Invertido, o pill arredondado ia até a borda da tela e os cantos
|
|
186
228
|
descobriam o fundo branco da página, que lia como defeito. */
|
|
187
229
|
<div className={cn('bg-[#f0f2f5] px-2 py-2', className)}>
|
|
230
|
+
{/* Uma linha com scroll no celular e wrap no desktop: em 375px seis chips em wrap empurrariam
|
|
231
|
+
o campo para fora da tela, e o campo é a razão de a barra existir. */}
|
|
232
|
+
{quickReplies && quickReplies.length > 0 && (
|
|
233
|
+
<div
|
|
234
|
+
className={cn(
|
|
235
|
+
'mb-2 flex flex-nowrap gap-1 overflow-x-auto px-1 sm:flex-wrap sm:overflow-x-visible [scrollbar-width:none] [&::-webkit-scrollbar]:hidden',
|
|
236
|
+
classNames?.quickReplies,
|
|
237
|
+
)}
|
|
238
|
+
>
|
|
239
|
+
{quickReplies.map((quickReply) => (
|
|
240
|
+
<button
|
|
241
|
+
key={quickReply.key}
|
|
242
|
+
type="button"
|
|
243
|
+
// Preenche o campo em vez de enviar: mensagem pronta é ponto de partida, e quem
|
|
244
|
+
// atende quase sempre ajusta uma palavra antes de mandar. Enviar direto no clique
|
|
245
|
+
// transformaria um toque errado em mensagem entregue ao cliente.
|
|
246
|
+
onClick={() => {
|
|
247
|
+
setText(resolveQuickReply(quickReply, quickReplyVariables))
|
|
248
|
+
textareaRef.current?.focus()
|
|
249
|
+
}}
|
|
250
|
+
className={cn(
|
|
251
|
+
'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',
|
|
252
|
+
classNames?.quickReply,
|
|
253
|
+
)}
|
|
254
|
+
>
|
|
255
|
+
{quickReply.label}
|
|
256
|
+
</button>
|
|
257
|
+
))}
|
|
258
|
+
</div>
|
|
259
|
+
)}
|
|
260
|
+
|
|
188
261
|
{attachments.length > 0 && (
|
|
189
262
|
<div className="flex gap-2 px-1 pb-2 overflow-x-auto">
|
|
190
263
|
{attachments.map((a, i) => (
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test'
|
|
2
|
+
import { renderToStaticMarkup } from 'react-dom/server'
|
|
3
|
+
|
|
4
|
+
import { RichMessageComposer, type RichComposerQuickReply, type RichComposerVariable } from './RichMessageComposer'
|
|
5
|
+
|
|
6
|
+
const noop = () => {}
|
|
7
|
+
|
|
8
|
+
const QUICK_REPLIES: RichComposerQuickReply[] = [
|
|
9
|
+
{ id: 'greeting', label: 'Saudação', text: 'Olá!', tooltip: 'Abre o atendimento' },
|
|
10
|
+
]
|
|
11
|
+
|
|
12
|
+
const VARIABLES: RichComposerVariable[] = [
|
|
13
|
+
{ id: 'name', label: 'Nome do cliente', value: '{{nome}}' },
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
describe('RichMessageComposer', () => {
|
|
17
|
+
it('mostra as dicas padrão em todas as ações da barra', () => {
|
|
18
|
+
const markup = renderToStaticMarkup(
|
|
19
|
+
<RichMessageComposer value="" onChange={noop} onSend={noop} onAttachFiles={noop} />,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
for (const tooltip of ['Negrito', 'Itálico', 'Tachado', 'Monoespaçado', 'Anexar arquivo', 'Enviar']) {
|
|
23
|
+
expect(markup).toContain(`title="${tooltip}"`)
|
|
24
|
+
}
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it('deixa o host trocar o texto de uma dica sem mexer nas outras', () => {
|
|
28
|
+
const markup = renderToStaticMarkup(
|
|
29
|
+
<RichMessageComposer value="" onChange={noop} onSend={noop} tooltips={{ bold: 'Deixar em negrito' }} />,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
expect(markup).toContain('title="Deixar em negrito"')
|
|
33
|
+
expect(markup).not.toContain('title="Negrito"')
|
|
34
|
+
expect(markup).toContain('title="Itálico"')
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it('esconde só a ação que o host desligou', () => {
|
|
38
|
+
const markup = renderToStaticMarkup(
|
|
39
|
+
<RichMessageComposer value="" onChange={noop} onSend={noop} toolbar={{ monospace: false }} />,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
expect(markup).not.toContain('title="Monoespaçado"')
|
|
43
|
+
expect(markup).toContain('title="Negrito"')
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('não desenha o anexo quando não há como anexar', () => {
|
|
47
|
+
const markup = renderToStaticMarkup(<RichMessageComposer value="" onChange={noop} onSend={noop} />)
|
|
48
|
+
|
|
49
|
+
expect(markup).not.toContain('title="Anexar arquivo"')
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('desenha os chips de resposta rápida com o rótulo e a dica do host', () => {
|
|
53
|
+
const markup = renderToStaticMarkup(
|
|
54
|
+
<RichMessageComposer value="" onChange={noop} onSend={noop} quickReplies={QUICK_REPLIES} />,
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
expect(markup).toContain('Saudação')
|
|
58
|
+
expect(markup).toContain('title="Abre o atendimento"')
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it('só oferece variáveis quando o host manda alguma', () => {
|
|
62
|
+
const semVariaveis = renderToStaticMarkup(<RichMessageComposer value="" onChange={noop} onSend={noop} />)
|
|
63
|
+
const comVariaveis = renderToStaticMarkup(
|
|
64
|
+
<RichMessageComposer value="" onChange={noop} onSend={noop} variables={VARIABLES} />,
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
expect(semVariaveis).not.toContain('title="Variáveis disponíveis"')
|
|
68
|
+
expect(comVariaveis).toContain('title="Variáveis disponíveis"')
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it('cede o lugar do enviar à ação ociosa enquanto não há texto', () => {
|
|
72
|
+
const vazio = renderToStaticMarkup(
|
|
73
|
+
<RichMessageComposer value="" onChange={noop} onSend={noop} idleAction={<button aria-label="Gravar áudio" />} />,
|
|
74
|
+
)
|
|
75
|
+
const comTexto = renderToStaticMarkup(
|
|
76
|
+
<RichMessageComposer value="oi" onChange={noop} onSend={noop} idleAction={<button aria-label="Gravar áudio" />} />,
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
expect(vazio).toContain('aria-label="Gravar áudio"')
|
|
80
|
+
expect(vazio).not.toContain('title="Enviar"')
|
|
81
|
+
expect(comTexto).toContain('title="Enviar"')
|
|
82
|
+
})
|
|
83
|
+
})
|