@adatechnology/conversations-ui 0.1.1 → 0.2.1
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/{ConversationSimulatorPanel--5fIzXWY.d.ts → ConversationSimulatorPanel-ikgmlciE.d.ts} +103 -1
- package/dist/{chunk-BJNRLLDO.js → chunk-NHQDQJL2.js} +769 -244
- package/dist/index.d.ts +304 -9
- package/dist/index.js +2181 -507
- package/dist/preview/index.d.ts +2 -2
- package/dist/preview/index.js +147 -1
- package/dist/styles.css +135 -0
- package/package.json +1 -1
- package/src/MessageComposer.test.tsx +14 -0
- package/src/MessageComposer.tsx +327 -73
- package/src/RichMessageComposer.test.tsx +22 -4
- package/src/RichMessageComposer.tsx +674 -370
- package/src/index.ts +28 -1
- package/src/preview/createMockConversationsApi.ts +184 -0
- package/src/providers/types.ts +34 -0
- package/src/quickReplies/AttachmentsFormSection.tsx +160 -0
- package/src/quickReplies/QuickRepliesPicker.test.tsx +114 -0
- package/src/quickReplies/QuickRepliesPicker.tsx +188 -0
- package/src/quickReplies/QuickRepliesWorkspace.test.tsx +32 -0
- package/src/quickReplies/QuickRepliesWorkspace.tsx +395 -0
- package/src/quickReplies/createUploadQueue.test.ts +106 -0
- package/src/quickReplies/createUploadQueue.ts +69 -0
- package/src/quickReplies/index.ts +5 -0
- package/src/quickReplies/labels.ts +124 -0
- package/src/quickReplies/quickReply.types.ts +74 -0
- package/src/quickReplies/quickReplyAttachmentUpload.test.ts +76 -0
- package/src/quickReplies/quickReplyAttachmentUpload.ts +81 -0
- package/src/quickReplies/quickReplyAttachments.test.ts +467 -0
- package/src/quickReplies/quickReplyAttachments.ts +328 -0
- package/src/quickReplies/quickReplySearch.test.ts +88 -0
- package/src/quickReplies/quickReplySearch.ts +104 -0
- package/src/quickReplies/quickReplyShortcut.test.ts +38 -0
- package/src/quickReplies/quickReplyShortcut.ts +46 -0
- package/src/quickReplies/resolveConversationVariables.test.ts +63 -0
- package/src/quickReplies/resolveConversationVariables.ts +41 -0
- package/src/quickReplies/useQuickRepliesPicker.test.ts +20 -0
- package/src/quickReplies/useQuickRepliesPicker.ts +121 -0
- package/src/quickReplies/useQuickRepliesWorkspace.test.ts +222 -0
- package/src/quickReplies/useQuickRepliesWorkspace.ts +426 -0
- package/src/quickReplies/useQuickReplyAttachmentUploads.ts +159 -0
- package/src/styles.css +87 -0
- package/src/workspace/ConversationPane.tsx +137 -73
- package/src/workspace/ConversationsWorkspace.tsx +16 -1
- package/src/workspace/QueuedAttachmentsList.test.ts +27 -0
- package/src/workspace/QueuedAttachmentsList.tsx +198 -0
- package/src/workspace/index.ts +1 -0
- package/src/workspace/labels.ts +14 -0
- package/src/workspace/useComposerAttachmentRetry.ts +169 -0
- package/src/workspace/useComposerQueue.ts +229 -0
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* última mensagem, outro engolia falha de anexo, outro não abria a biblioteca de arquivos.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import { useEffect, useRef, useState, type ReactNode } from 'react'
|
|
8
|
+
import { useCallback, useEffect, useRef, useState, type ReactNode } from 'react'
|
|
9
9
|
|
|
10
10
|
import { AudioRecorderButton } from '../AudioRecorderButton'
|
|
11
11
|
import { ConversationContextPanel, type ConversationContextEntry } from '../ConversationContextPanel'
|
|
@@ -18,11 +18,7 @@ import { MessageComposer, applyQuickReplyVariables, type QuickReply } from '../M
|
|
|
18
18
|
import { RichMessageComposer, type RichComposerVariable } from '../RichMessageComposer'
|
|
19
19
|
import { WindowExpiredNotice, isWindowBlocking } from '../WindowExpiredNotice'
|
|
20
20
|
import { windowOf } from '../conversationWindow'
|
|
21
|
-
import {
|
|
22
|
-
buildTranscriptFilename,
|
|
23
|
-
buildTranscriptText,
|
|
24
|
-
downloadTextFile,
|
|
25
|
-
} from '../conversationTranscript'
|
|
21
|
+
import { buildTranscriptFilename, buildTranscriptText, downloadTextFile } from '../conversationTranscript'
|
|
26
22
|
import { useConversationContext } from '../hooks/useConversationContext'
|
|
27
23
|
import { useConversationMessages } from '../hooks/useConversationMessages'
|
|
28
24
|
import { useConversationRealtime } from '../hooks/useConversationRealtime'
|
|
@@ -30,6 +26,15 @@ import { useScrollToLatestMessage } from '../hooks/useScrollToLatestMessage'
|
|
|
30
26
|
import { useConversations } from '../providers/ConversationsProvider'
|
|
31
27
|
import type { ConversationSummary } from '../providers/types'
|
|
32
28
|
import type { ConversationsWorkspaceLabels } from './labels'
|
|
29
|
+
import type {
|
|
30
|
+
ConversationVariable,
|
|
31
|
+
QueuedAttachment,
|
|
32
|
+
QuickReply as SavedQuickReply,
|
|
33
|
+
} from '../quickReplies/quickReply.types'
|
|
34
|
+
import { queuedAttachmentsFromQuickReply } from '../quickReplies/quickReplyAttachments'
|
|
35
|
+
import { resolveConversationVariables } from '../quickReplies/resolveConversationVariables'
|
|
36
|
+
import { QueuedAttachmentsList } from './QueuedAttachmentsList'
|
|
37
|
+
import { useComposerQueue } from './useComposerQueue'
|
|
33
38
|
|
|
34
39
|
export interface ConversationPaneProps {
|
|
35
40
|
readonly conversation: ConversationSummary
|
|
@@ -49,6 +54,7 @@ export interface ConversationPaneProps {
|
|
|
49
54
|
* Recebe o contexto junto porque o dado que interessa à variável (o nome que o bot perguntou,
|
|
50
55
|
* por exemplo) vive no contexto do fluxo, não no resumo da listagem.
|
|
51
56
|
*/
|
|
57
|
+
/** @deprecated Use `conversationVariablesFor`, que alimenta os dois composers com uma lista só. */
|
|
52
58
|
readonly quickReplyVariablesFor?: (
|
|
53
59
|
conversation: ConversationSummary,
|
|
54
60
|
context: Record<string, unknown> | undefined,
|
|
@@ -87,11 +93,22 @@ export interface ConversationPaneProps {
|
|
|
87
93
|
* é menos coisa na tela.
|
|
88
94
|
*/
|
|
89
95
|
readonly composer?: 'simple' | 'rich'
|
|
90
|
-
/**
|
|
96
|
+
/**
|
|
97
|
+
* Valores que o operador insere sem digitar. Só o composer `rich` os oferece.
|
|
98
|
+
* @deprecated Use `conversationVariablesFor`.
|
|
99
|
+
*/
|
|
91
100
|
readonly composerVariablesFor?: (
|
|
92
101
|
conversation: ConversationSummary,
|
|
93
102
|
context: Record<string, unknown> | undefined,
|
|
94
103
|
) => readonly RichComposerVariable[]
|
|
104
|
+
/**
|
|
105
|
+
* Dados da conversa que o texto pode citar, numa lista só. Presente, manda sobre
|
|
106
|
+
* `quickReplyVariablesFor` e `composerVariablesFor`.
|
|
107
|
+
*/
|
|
108
|
+
readonly conversationVariablesFor?: (
|
|
109
|
+
conversation: ConversationSummary,
|
|
110
|
+
context: Record<string, unknown> | undefined,
|
|
111
|
+
) => readonly ConversationVariable[]
|
|
95
112
|
/**
|
|
96
113
|
* Fila de anexos com legenda, como no WhatsApp: os arquivos escolhidos ficam visíveis acima da
|
|
97
114
|
* barra e saem junto com o texto escrito. Ausente, o clipe manda cada arquivo na hora — o que
|
|
@@ -124,6 +141,7 @@ export function ConversationPane({
|
|
|
124
141
|
onAttach,
|
|
125
142
|
composer = 'simple',
|
|
126
143
|
composerVariablesFor,
|
|
144
|
+
conversationVariablesFor,
|
|
127
145
|
onSendAttachments,
|
|
128
146
|
onRecordAudio,
|
|
129
147
|
}: ConversationPaneProps) {
|
|
@@ -139,17 +157,51 @@ export function ConversationPane({
|
|
|
139
157
|
const [sendFailure, setSendFailure] = useState<string | undefined>(undefined)
|
|
140
158
|
const [selectedMessageIds, setSelectedMessageIds] = useState<ReadonlySet<string>>(new Set())
|
|
141
159
|
const [draft, setDraft] = useState(initialComposerText ?? '')
|
|
142
|
-
const [queuedFiles, setQueuedFiles] = useState<readonly File[]>([])
|
|
143
|
-
const [isSendingDraft, setIsSendingDraft] = useState(false)
|
|
144
|
-
/** Ref, não estado: entre dois cliques seguidos o React ainda não teria repintado a trava. */
|
|
145
|
-
const sendInFlightRef = useRef(false)
|
|
146
160
|
|
|
147
|
-
|
|
148
|
-
|
|
161
|
+
const {
|
|
162
|
+
queue,
|
|
163
|
+
enqueueAttachments,
|
|
164
|
+
attachmentStatus,
|
|
165
|
+
isSendingDraft,
|
|
166
|
+
handleRichSend,
|
|
167
|
+
removeQueuedAttachment,
|
|
168
|
+
retryQueuedAttachment,
|
|
169
|
+
retryingKeys,
|
|
170
|
+
} = useComposerQueue({
|
|
171
|
+
conversationId: conversation.id,
|
|
172
|
+
draft,
|
|
173
|
+
setDraft,
|
|
174
|
+
api,
|
|
175
|
+
labels: { sendFailure: labels.sendFailure, attachFailure: labels.attachFailure },
|
|
176
|
+
...(onSendAttachments ? { onSendAttachments } : {}),
|
|
177
|
+
refetch,
|
|
178
|
+
setSendFailure,
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Uma promessa por `uploadId`, nunca duas: sem o cache, cada render da lista de anexos disparava
|
|
183
|
+
* de novo a URL assinada do mesmo arquivo (M4) — a função abaixo é estável, mas o item pode
|
|
184
|
+
* remontar por causa do estado de envio.
|
|
185
|
+
*/
|
|
186
|
+
const thumbnailUrlCacheRef = useRef(new Map<string, Promise<string>>())
|
|
187
|
+
const getQueuedAttachmentThumbnailUrl = useCallback(
|
|
188
|
+
(uploadId: string): Promise<string> => {
|
|
189
|
+
const cached = thumbnailUrlCacheRef.current.get(uploadId)
|
|
190
|
+
if (cached) return cached
|
|
191
|
+
const pending = api.getDocumentUrl(uploadId, 'inline')
|
|
192
|
+
thumbnailUrlCacheRef.current.set(uploadId, pending)
|
|
193
|
+
pending.catch(() => thumbnailUrlCacheRef.current.delete(uploadId))
|
|
194
|
+
return pending
|
|
195
|
+
},
|
|
196
|
+
[api],
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
// Trocar de conversa zera seleção de mensagem e rascunho — pertencem à thread, e levá-los adiante
|
|
200
|
+
// faria copiar o trecho errado ou responder ao cliente errado. A fila de anexos se reseta sozinha
|
|
201
|
+
// dentro de `useComposerQueue`, keyed pelo mesmo `conversation.id`.
|
|
149
202
|
useEffect(() => {
|
|
150
203
|
setSelectedMessageIds(new Set())
|
|
151
204
|
setDraft(initialComposerText ?? '')
|
|
152
|
-
setQueuedFiles([])
|
|
153
205
|
}, [conversation.id, initialComposerText])
|
|
154
206
|
|
|
155
207
|
function toggleMessageSelected(messageId: string): void {
|
|
@@ -218,35 +270,6 @@ export function ConversationPane({
|
|
|
218
270
|
await runSend(() => api.sendTemplate(conversation.id, {}), labels.sendFailure)
|
|
219
271
|
}
|
|
220
272
|
|
|
221
|
-
/**
|
|
222
|
-
* O texto escrito é a legenda do anexo, não uma segunda mensagem: no WhatsApp a foto chega com a
|
|
223
|
-
* frase embaixo, e mandar as duas separadas invertia a ordem quando a mídia demorava a subir.
|
|
224
|
-
*/
|
|
225
|
-
async function handleRichSend(): Promise<void> {
|
|
226
|
-
// O upload da mídia demora e não dá retorno na tela; sem esta trava o segundo clique — ou o
|
|
227
|
-
// Enter impaciente — mandava o mesmo arquivo outra vez.
|
|
228
|
-
if (sendInFlightRef.current) return
|
|
229
|
-
sendInFlightRef.current = true
|
|
230
|
-
setIsSendingDraft(true)
|
|
231
|
-
try {
|
|
232
|
-
if (onSendAttachments && queuedFiles.length > 0) {
|
|
233
|
-
const files = queuedFiles
|
|
234
|
-
const caption = draft
|
|
235
|
-
const didSend = await runSend(() => onSendAttachments(files, caption), labels.attachFailure)
|
|
236
|
-
if (didSend) {
|
|
237
|
-
setQueuedFiles([])
|
|
238
|
-
setDraft('')
|
|
239
|
-
}
|
|
240
|
-
return
|
|
241
|
-
}
|
|
242
|
-
if (!draft.trim()) return
|
|
243
|
-
if (await handleSend(draft)) setDraft('')
|
|
244
|
-
} finally {
|
|
245
|
-
sendInFlightRef.current = false
|
|
246
|
-
setIsSendingDraft(false)
|
|
247
|
-
}
|
|
248
|
-
}
|
|
249
|
-
|
|
250
273
|
async function handleAttach(file: File): Promise<void> {
|
|
251
274
|
if (!onAttach) return
|
|
252
275
|
await runSend(() => onAttach(file), labels.attachFailure)
|
|
@@ -264,7 +287,11 @@ export function ConversationPane({
|
|
|
264
287
|
}
|
|
265
288
|
|
|
266
289
|
const contextEntries = contextEntriesOf?.(conversationContext)
|
|
267
|
-
const composerVariables =
|
|
290
|
+
const { quickReplyVariables, composerVariables } = resolveConversationVariables({
|
|
291
|
+
conversationVariables: conversationVariablesFor?.(conversation, conversationContext),
|
|
292
|
+
quickReplyVariables: quickReplyVariablesFor?.(conversation, conversationContext),
|
|
293
|
+
composerVariables: composerVariablesFor?.(conversation, conversationContext),
|
|
294
|
+
})
|
|
268
295
|
/**
|
|
269
296
|
* As mesmas `quickReplies` do composer simples, com as variáveis já resolvidas — o campo rico
|
|
270
297
|
* recebe texto pronto. Uma segunda lista, só de formato diferente, é como as telas divergiam.
|
|
@@ -274,9 +301,29 @@ export function ConversationPane({
|
|
|
274
301
|
label: reply.label,
|
|
275
302
|
text:
|
|
276
303
|
typeof reply.text === 'string'
|
|
277
|
-
? applyQuickReplyVariables(reply.text,
|
|
278
|
-
: reply.text(
|
|
304
|
+
? applyQuickReplyVariables(reply.text, quickReplyVariables ?? {})
|
|
305
|
+
: reply.text(quickReplyVariables ?? {}),
|
|
279
306
|
}))
|
|
307
|
+
/**
|
|
308
|
+
* Botão de raio e atalho `/` dos dois composers. `listQuickReplies` é a capacidade — sem ela na
|
|
309
|
+
* porta do host, nenhum dos dois aparece, em vez de um botão que abre uma lista sempre vazia.
|
|
310
|
+
*/
|
|
311
|
+
const savedQuickReplies = api.listQuickReplies
|
|
312
|
+
? {
|
|
313
|
+
// Arrow em vez de repassar o método direto: `api.listQuickReplies` solto perde o `this` do
|
|
314
|
+
// objeto que o implementa, e um cliente HTTP real costuma depender dele internamente.
|
|
315
|
+
listQuickReplies: (params?: { search?: string }) => api.listQuickReplies!(params),
|
|
316
|
+
conversationId: conversation.id,
|
|
317
|
+
variables: quickReplyVariables,
|
|
318
|
+
hasAttachmentsCapability: Boolean(api.sendStoredAttachments),
|
|
319
|
+
// Empurra os anexos da mensagem escolhida como itens guardados (QR-32) — sem a porta, a
|
|
320
|
+
// linha do picker já avisou e o texto entra sozinho, sem silenciosamente perder o anexo.
|
|
321
|
+
onSelect: (quickReply: SavedQuickReply) => {
|
|
322
|
+
const attachments = queuedAttachmentsFromQuickReply(quickReply, Boolean(api.sendStoredAttachments))
|
|
323
|
+
enqueueAttachments(attachments)
|
|
324
|
+
},
|
|
325
|
+
}
|
|
326
|
+
: undefined
|
|
280
327
|
const botOwnsConversation = Boolean(requireTakeoverToReply) && conversation.mode !== 'human'
|
|
281
328
|
|
|
282
329
|
return (
|
|
@@ -337,10 +384,20 @@ export function ConversationPane({
|
|
|
337
384
|
<div className="cv-workspace-selection">
|
|
338
385
|
<span>{labels.messagesSelected(selectedMessageIds.size)}</span>
|
|
339
386
|
<div className="cv-workspace-selection__actions">
|
|
340
|
-
<button
|
|
387
|
+
<button
|
|
388
|
+
data-cv-tooltip={labels.bulkClear}
|
|
389
|
+
aria-label={labels.bulkClear}
|
|
390
|
+
type="button"
|
|
391
|
+
onClick={() => setSelectedMessageIds(new Set())}
|
|
392
|
+
>
|
|
341
393
|
{labels.bulkClear}
|
|
342
394
|
</button>
|
|
343
|
-
<button
|
|
395
|
+
<button
|
|
396
|
+
data-cv-tooltip={labels.copySelected}
|
|
397
|
+
aria-label={labels.copySelected}
|
|
398
|
+
type="button"
|
|
399
|
+
onClick={copySelectedMessages}
|
|
400
|
+
>
|
|
344
401
|
{labels.copySelected}
|
|
345
402
|
</button>
|
|
346
403
|
</div>
|
|
@@ -354,10 +411,7 @@ export function ConversationPane({
|
|
|
354
411
|
) : null}
|
|
355
412
|
|
|
356
413
|
{blocked ? (
|
|
357
|
-
<WindowExpiredNotice
|
|
358
|
-
disabled={busy}
|
|
359
|
-
onSendTemplate={() => void handleSendTemplate()}
|
|
360
|
-
/>
|
|
414
|
+
<WindowExpiredNotice disabled={busy} onSendTemplate={() => void handleSendTemplate()} />
|
|
361
415
|
) : botOwnsConversation ? (
|
|
362
416
|
// Responder com a conversa no bot atropelaria o fluxo automático no meio de uma pergunta.
|
|
363
417
|
<p className="cv-workspace-notice">{labels.takeoverToReply}</p>
|
|
@@ -367,7 +421,14 @@ export function ConversationPane({
|
|
|
367
421
|
onChange={setDraft}
|
|
368
422
|
onSend={() => void handleRichSend()}
|
|
369
423
|
{...(onSendAttachments
|
|
370
|
-
? {
|
|
424
|
+
? {
|
|
425
|
+
onAttachFiles: (files: FileList) =>
|
|
426
|
+
enqueueAttachments(
|
|
427
|
+
Array.from(files).map(
|
|
428
|
+
(file): QueuedAttachment => ({ kind: 'local', localId: crypto.randomUUID(), file }),
|
|
429
|
+
),
|
|
430
|
+
),
|
|
431
|
+
}
|
|
371
432
|
: onAttach
|
|
372
433
|
? {
|
|
373
434
|
onAttachFiles: (files: FileList) => {
|
|
@@ -377,7 +438,7 @@ export function ConversationPane({
|
|
|
377
438
|
: {})}
|
|
378
439
|
placeholder={labels.composerPlaceholder}
|
|
379
440
|
isSending={busy || isSendingDraft}
|
|
380
|
-
hasQueuedAttachments={
|
|
441
|
+
hasQueuedAttachments={queue.length > 0}
|
|
381
442
|
{...(onRecordAudio
|
|
382
443
|
? {
|
|
383
444
|
idleAction: (
|
|
@@ -388,31 +449,33 @@ export function ConversationPane({
|
|
|
388
449
|
),
|
|
389
450
|
}
|
|
390
451
|
: {})}
|
|
391
|
-
{...(
|
|
452
|
+
{...(queue.length > 0
|
|
392
453
|
? {
|
|
393
454
|
attachmentsPreview: (
|
|
394
|
-
<
|
|
395
|
-
{
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
455
|
+
<QueuedAttachmentsList
|
|
456
|
+
items={queue}
|
|
457
|
+
statusOf={(key) => attachmentStatus[key] ?? 'waiting'}
|
|
458
|
+
onRemove={removeQueuedAttachment}
|
|
459
|
+
onRetry={retryQueuedAttachment}
|
|
460
|
+
retryingKeys={retryingKeys}
|
|
461
|
+
getThumbnailUrl={getQueuedAttachmentThumbnailUrl}
|
|
462
|
+
busy={isSendingDraft}
|
|
463
|
+
labels={{
|
|
464
|
+
remove: labels.attachmentRemove,
|
|
465
|
+
waiting: labels.attachmentWaiting,
|
|
466
|
+
sending: labels.attachmentSending,
|
|
467
|
+
sent: labels.attachmentSent,
|
|
468
|
+
failed: labels.attachmentFailed,
|
|
469
|
+
skipped: labels.attachmentSkipped,
|
|
470
|
+
retry: labels.attachmentRetry,
|
|
471
|
+
}}
|
|
472
|
+
/>
|
|
411
473
|
),
|
|
412
474
|
}
|
|
413
475
|
: {})}
|
|
414
476
|
{...(richQuickReplies ? { quickReplies: [...richQuickReplies] } : {})}
|
|
415
477
|
{...(composerVariables ? { variables: [...composerVariables] } : {})}
|
|
478
|
+
{...(savedQuickReplies ? { savedQuickReplies } : {})}
|
|
416
479
|
/>
|
|
417
480
|
) : (
|
|
418
481
|
<MessageComposer
|
|
@@ -424,7 +487,8 @@ export function ConversationPane({
|
|
|
424
487
|
{...(onAttach ? { onAttach: (file: File) => void handleAttach(file) } : {})}
|
|
425
488
|
placeholder={labels.composerPlaceholder}
|
|
426
489
|
{...(quickReplies ? { quickReplies } : {})}
|
|
427
|
-
{...(
|
|
490
|
+
{...(quickReplyVariables ? { quickReplyVariables } : {})}
|
|
491
|
+
{...(savedQuickReplies ? { savedQuickReplies } : {})}
|
|
428
492
|
/>
|
|
429
493
|
)}
|
|
430
494
|
</div>
|
|
@@ -39,6 +39,7 @@ import { ConversationsInboxList } from './ConversationsInboxList'
|
|
|
39
39
|
import { DEFAULT_CONVERSATIONS_WORKSPACE_LABELS, type ConversationsWorkspaceLabels } from './labels'
|
|
40
40
|
import { useConversationsInbox, type UseConversationsInboxResult } from './useConversationsInbox'
|
|
41
41
|
import { TooltipLayer } from '../Tooltip'
|
|
42
|
+
import type { ConversationVariable } from '../quickReplies/quickReply.types'
|
|
42
43
|
|
|
43
44
|
export type SimulatorTransportParams = {
|
|
44
45
|
readonly conversationId: string
|
|
@@ -98,6 +99,7 @@ export interface ConversationsWorkspaceProps {
|
|
|
98
99
|
readonly initialWhatsappNumber?: string | undefined
|
|
99
100
|
readonly simulator?: ConversationsWorkspaceSimulator
|
|
100
101
|
readonly quickReplies?: readonly QuickReply[]
|
|
102
|
+
/** @deprecated Use `conversationVariablesFor`, que alimenta os dois composers com uma lista só. */
|
|
101
103
|
readonly quickReplyVariablesFor?: (
|
|
102
104
|
conversation: ConversationSummary,
|
|
103
105
|
context: Record<string, unknown> | undefined,
|
|
@@ -114,11 +116,22 @@ export interface ConversationsWorkspaceProps {
|
|
|
114
116
|
readonly initialComposerText?: string | undefined
|
|
115
117
|
/** `rich` troca o campo simples pelo texto com a formatação do WhatsApp desenhada ao escrever. */
|
|
116
118
|
readonly composer?: 'simple' | 'rich'
|
|
117
|
-
/**
|
|
119
|
+
/**
|
|
120
|
+
* Valores que o operador insere sem digitar. Só o composer `rich` os oferece.
|
|
121
|
+
* @deprecated Use `conversationVariablesFor`.
|
|
122
|
+
*/
|
|
118
123
|
readonly composerVariablesFor?: (
|
|
119
124
|
conversation: ConversationSummary,
|
|
120
125
|
context: Record<string, unknown> | undefined,
|
|
121
126
|
) => readonly RichComposerVariable[]
|
|
127
|
+
/**
|
|
128
|
+
* Dados da conversa que o texto pode citar, numa lista só. Presente, manda sobre
|
|
129
|
+
* `quickReplyVariablesFor` e `composerVariablesFor`.
|
|
130
|
+
*/
|
|
131
|
+
readonly conversationVariablesFor?: (
|
|
132
|
+
conversation: ConversationSummary,
|
|
133
|
+
context: Record<string, unknown> | undefined,
|
|
134
|
+
) => readonly ConversationVariable[]
|
|
122
135
|
/** Fila de anexos com legenda, como no WhatsApp. Ausente, o clipe manda cada arquivo na hora. */
|
|
123
136
|
readonly onSendAttachments?: (
|
|
124
137
|
conversation: ConversationSummary,
|
|
@@ -162,6 +175,7 @@ export function ConversationsWorkspace({
|
|
|
162
175
|
initialComposerText,
|
|
163
176
|
composer,
|
|
164
177
|
composerVariablesFor,
|
|
178
|
+
conversationVariablesFor,
|
|
165
179
|
onSendAttachments,
|
|
166
180
|
onRecordAudio,
|
|
167
181
|
contextEntriesOf,
|
|
@@ -360,6 +374,7 @@ export function ConversationsWorkspace({
|
|
|
360
374
|
{...(initialComposerText ? { initialComposerText } : {})}
|
|
361
375
|
{...(composer ? { composer } : {})}
|
|
362
376
|
{...(composerVariablesFor ? { composerVariablesFor } : {})}
|
|
377
|
+
{...(conversationVariablesFor ? { conversationVariablesFor } : {})}
|
|
363
378
|
{...(onSendAttachments
|
|
364
379
|
? {
|
|
365
380
|
onSendAttachments: (files: readonly File[], caption: string) =>
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test'
|
|
2
|
+
|
|
3
|
+
import { departedItemsOf } from './QueuedAttachmentsList'
|
|
4
|
+
import type { QueuedAttachment } from '../quickReplies/quickReply.types'
|
|
5
|
+
|
|
6
|
+
function storedItem(uploadId: string): QueuedAttachment {
|
|
7
|
+
return { kind: 'stored', uploadId, filename: `${uploadId}.pdf`, mimeType: 'application/pdf', sizeBytes: 100 }
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
describe('departedItemsOf', () => {
|
|
11
|
+
it('não aponta saída quando a fila não muda', () => {
|
|
12
|
+
const items = [storedItem('a'), storedItem('b')]
|
|
13
|
+
expect(departedItemsOf(items, items)).toEqual([])
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
it('aponta só o item que saiu da fila', () => {
|
|
17
|
+
const previouslyRendered = [storedItem('a'), storedItem('b')]
|
|
18
|
+
const items = [storedItem('a')]
|
|
19
|
+
expect(departedItemsOf(previouslyRendered, items)).toEqual([storedItem('b')])
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
it('não aponta saída quando um item entra na fila', () => {
|
|
23
|
+
const previouslyRendered = [storedItem('a')]
|
|
24
|
+
const items = [storedItem('a'), storedItem('b')]
|
|
25
|
+
expect(departedItemsOf(previouslyRendered, items)).toEqual([])
|
|
26
|
+
})
|
|
27
|
+
})
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fila de anexos do composer — itens `local` (ainda não subiram) e `stored` (mensagem pronta,
|
|
3
|
+
* enviados por referência). Mostra nome, tipo, tamanho, miniatura de imagem sob demanda e o estado
|
|
4
|
+
* de envio de cada item (QR-32, QR-33, QR-47).
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { useEffect, useRef, useState } from 'react'
|
|
8
|
+
|
|
9
|
+
import { formatFileSize } from '../lib/format'
|
|
10
|
+
import { attachmentKey, type AttachmentSendStatus } from '../quickReplies/quickReplyAttachments'
|
|
11
|
+
import type { QueuedAttachment } from '../quickReplies/quickReply.types'
|
|
12
|
+
|
|
13
|
+
export type QueuedAttachmentsListLabels = {
|
|
14
|
+
readonly remove: string
|
|
15
|
+
readonly waiting: string
|
|
16
|
+
readonly sending: string
|
|
17
|
+
readonly sent: string
|
|
18
|
+
readonly failed: string
|
|
19
|
+
readonly skipped: string
|
|
20
|
+
readonly retry: string
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export type QueuedAttachmentsListProps = {
|
|
24
|
+
readonly items: readonly QueuedAttachment[]
|
|
25
|
+
readonly statusOf: (key: string) => AttachmentSendStatus
|
|
26
|
+
readonly onRemove: (item: QueuedAttachment) => void
|
|
27
|
+
readonly onRetry?: (item: QueuedAttachment) => void
|
|
28
|
+
/** Chaves com retry avulso em voo — desabilita o botão "Tentar de novo" desse item específico. */
|
|
29
|
+
readonly retryingKeys?: ReadonlySet<string>
|
|
30
|
+
readonly getThumbnailUrl?: (uploadId: string) => Promise<string>
|
|
31
|
+
readonly labels: QueuedAttachmentsListLabels
|
|
32
|
+
readonly busy: boolean
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function nameOf(item: QueuedAttachment): string {
|
|
36
|
+
return item.kind === 'local' ? item.file.name : item.filename
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function mimeTypeOf(item: QueuedAttachment): string {
|
|
40
|
+
return item.kind === 'local' ? item.file.type : item.mimeType
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function sizeOf(item: QueuedAttachment): number {
|
|
44
|
+
return item.kind === 'local' ? item.file.size : item.sizeBytes
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Espaço reservado do tamanho final enquanto a miniatura carrega — sem pulo de layout (QR-47). */
|
|
48
|
+
function AttachmentThumbnail({
|
|
49
|
+
item,
|
|
50
|
+
getThumbnailUrl,
|
|
51
|
+
}: {
|
|
52
|
+
readonly item: QueuedAttachment
|
|
53
|
+
readonly getThumbnailUrl?: (uploadId: string) => Promise<string>
|
|
54
|
+
}) {
|
|
55
|
+
const isImage = mimeTypeOf(item).startsWith('image/')
|
|
56
|
+
const [url, setUrl] = useState<string | undefined>(item.kind === 'local' ? undefined : item.previewUrl)
|
|
57
|
+
const localFile = item.kind === 'local' ? item.file : undefined
|
|
58
|
+
const uploadId = item.kind === 'stored' ? item.uploadId : undefined
|
|
59
|
+
|
|
60
|
+
// Só o `File` decide a URL de objeto local: incluir `item` inteiro (H4) recriava e revogava a URL
|
|
61
|
+
// a cada render em que a identidade do objeto da fila mudasse por outro motivo (status, por ex.).
|
|
62
|
+
useEffect(() => {
|
|
63
|
+
if (!isImage || !localFile) return
|
|
64
|
+
const objectUrl = URL.createObjectURL(localFile)
|
|
65
|
+
setUrl(objectUrl)
|
|
66
|
+
return () => URL.revokeObjectURL(objectUrl)
|
|
67
|
+
}, [isImage, localFile])
|
|
68
|
+
|
|
69
|
+
// `url` fica de fora do array por propósito: é este efeito que o define via `setUrl`, incluí-lo
|
|
70
|
+
// reexecutaria a busca a cada resolução. Só `uploadId` reinicia a busca da miniatura remota.
|
|
71
|
+
useEffect(() => {
|
|
72
|
+
if (!isImage || !uploadId || !getThumbnailUrl) return
|
|
73
|
+
let cancelled = false
|
|
74
|
+
getThumbnailUrl(uploadId)
|
|
75
|
+
.then((resolved) => {
|
|
76
|
+
if (!cancelled) setUrl(resolved)
|
|
77
|
+
})
|
|
78
|
+
.catch(() => undefined)
|
|
79
|
+
return () => {
|
|
80
|
+
cancelled = true
|
|
81
|
+
}
|
|
82
|
+
}, [isImage, uploadId, getThumbnailUrl])
|
|
83
|
+
|
|
84
|
+
if (!isImage) return null
|
|
85
|
+
return (
|
|
86
|
+
<span className="cv-attachment-item__thumbnail" aria-hidden="true">
|
|
87
|
+
{url ? <img src={url} alt="" /> : null}
|
|
88
|
+
</span>
|
|
89
|
+
)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Parte pura da transição: quais itens saíram da fila entre a última lista renderizada e a nova.
|
|
94
|
+
* Separada de `useDepartingItems` para ser testável sem montar um componente.
|
|
95
|
+
*/
|
|
96
|
+
export function departedItemsOf(
|
|
97
|
+
previouslyRendered: readonly QueuedAttachment[],
|
|
98
|
+
items: readonly QueuedAttachment[],
|
|
99
|
+
): readonly QueuedAttachment[] {
|
|
100
|
+
const currentKeys = new Set(items.map(attachmentKey))
|
|
101
|
+
return previouslyRendered.filter((item) => !currentKeys.has(attachmentKey(item)))
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Mantém o item visível por uma transição curta depois de sair da fila (QR-47), sem travar props. */
|
|
105
|
+
function useDepartingItems(items: readonly QueuedAttachment[]) {
|
|
106
|
+
const [rendered, setRendered] = useState(items)
|
|
107
|
+
const [departingKeys, setDepartingKeys] = useState<ReadonlySet<string>>(new Set())
|
|
108
|
+
// Espelha `rendered` num ref lido dentro do efeito: assim o array de dependências fica completo
|
|
109
|
+
// (só `items`, que é o que deve reiniciar a transição) sem o efeito reagir à própria escrita em
|
|
110
|
+
// `rendered` via `setRendered`, o que recriaria o timer em loop.
|
|
111
|
+
const renderedRef = useRef(rendered)
|
|
112
|
+
renderedRef.current = rendered
|
|
113
|
+
|
|
114
|
+
useEffect(() => {
|
|
115
|
+
const removedItems = departedItemsOf(renderedRef.current, items)
|
|
116
|
+
if (removedItems.length === 0) {
|
|
117
|
+
setRendered(items)
|
|
118
|
+
return
|
|
119
|
+
}
|
|
120
|
+
setDepartingKeys(new Set(removedItems.map(attachmentKey)))
|
|
121
|
+
const reduceMotion =
|
|
122
|
+
typeof window !== 'undefined' && window.matchMedia?.('(prefers-reduced-motion: reduce)').matches
|
|
123
|
+
const delay = reduceMotion ? 0 : 220
|
|
124
|
+
const timer = setTimeout(() => {
|
|
125
|
+
setRendered(items)
|
|
126
|
+
setDepartingKeys(new Set())
|
|
127
|
+
}, delay)
|
|
128
|
+
return () => clearTimeout(timer)
|
|
129
|
+
}, [items])
|
|
130
|
+
|
|
131
|
+
return { rendered: departingKeys.size > 0 ? [...rendered] : items, departingKeys }
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export function QueuedAttachmentsList({
|
|
135
|
+
items,
|
|
136
|
+
statusOf,
|
|
137
|
+
onRemove,
|
|
138
|
+
onRetry,
|
|
139
|
+
retryingKeys,
|
|
140
|
+
getThumbnailUrl,
|
|
141
|
+
labels,
|
|
142
|
+
busy,
|
|
143
|
+
}: QueuedAttachmentsListProps) {
|
|
144
|
+
const { rendered, departingKeys } = useDepartingItems(items)
|
|
145
|
+
if (rendered.length === 0) return null
|
|
146
|
+
|
|
147
|
+
const statusLabelOf = (status: AttachmentSendStatus): string => {
|
|
148
|
+
if (status === 'sending') return labels.sending
|
|
149
|
+
if (status === 'sent') return labels.sent
|
|
150
|
+
if (status === 'failed') return labels.failed
|
|
151
|
+
if (status === 'skipped') return labels.skipped
|
|
152
|
+
return labels.waiting
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return (
|
|
156
|
+
<ul className="cv-workspace-attachments" aria-busy={busy} aria-live="polite">
|
|
157
|
+
{rendered.map((item) => {
|
|
158
|
+
const key = attachmentKey(item)
|
|
159
|
+
const status = statusOf(key)
|
|
160
|
+
const isDeparting = departingKeys.has(key)
|
|
161
|
+
return (
|
|
162
|
+
<li
|
|
163
|
+
key={key}
|
|
164
|
+
className={`cv-attachment-item cv-attachment-item--${status}${isDeparting ? ' cv-attachment-item--departing' : ''}`}
|
|
165
|
+
>
|
|
166
|
+
<AttachmentThumbnail item={item} getThumbnailUrl={getThumbnailUrl} />
|
|
167
|
+
<span className="cv-attachment-item__info">
|
|
168
|
+
<span className="cv-attachment-item__name">{nameOf(item)}</span>
|
|
169
|
+
<span className="cv-attachment-item__meta">
|
|
170
|
+
{formatFileSize(sizeOf(item))} · {statusLabelOf(status)}
|
|
171
|
+
</span>
|
|
172
|
+
</span>
|
|
173
|
+
{(status === 'failed' || status === 'skipped') && onRetry ? (
|
|
174
|
+
<button
|
|
175
|
+
type="button"
|
|
176
|
+
className="cv-attachment-item__retry"
|
|
177
|
+
disabled={busy || (retryingKeys?.has(key) ?? false)}
|
|
178
|
+
aria-busy={retryingKeys?.has(key) ?? false}
|
|
179
|
+
onClick={() => onRetry(item)}
|
|
180
|
+
>
|
|
181
|
+
{labels.retry}
|
|
182
|
+
</button>
|
|
183
|
+
) : null}
|
|
184
|
+
<button
|
|
185
|
+
data-cv-tooltip={labels.remove}
|
|
186
|
+
type="button"
|
|
187
|
+
aria-label={labels.remove}
|
|
188
|
+
disabled={status === 'sending'}
|
|
189
|
+
onClick={() => onRemove(item)}
|
|
190
|
+
>
|
|
191
|
+
✕
|
|
192
|
+
</button>
|
|
193
|
+
</li>
|
|
194
|
+
)
|
|
195
|
+
})}
|
|
196
|
+
</ul>
|
|
197
|
+
)
|
|
198
|
+
}
|
package/src/workspace/index.ts
CHANGED
|
@@ -11,6 +11,7 @@ export { BulkTemplateModal } from './BulkTemplateModal'
|
|
|
11
11
|
export type { BulkTemplateModalProps } from './BulkTemplateModal'
|
|
12
12
|
export { ConversationsInboxList } from './ConversationsInboxList'
|
|
13
13
|
export type { ConversationsInboxListProps } from './ConversationsInboxList'
|
|
14
|
+
export { QueuedAttachmentsList } from './QueuedAttachmentsList'
|
|
14
15
|
export { useConversationsInbox, CONVERSATIONS_PER_PAGE } from './useConversationsInbox'
|
|
15
16
|
export type { UseConversationsInboxParams, UseConversationsInboxResult } from './useConversationsInbox'
|
|
16
17
|
export { DEFAULT_CONVERSATIONS_WORKSPACE_LABELS } from './labels'
|
package/src/workspace/labels.ts
CHANGED
|
@@ -36,6 +36,14 @@ export interface ConversationsWorkspaceLabels {
|
|
|
36
36
|
readonly attachFailure: string
|
|
37
37
|
/** Tira um arquivo da fila antes de enviar. */
|
|
38
38
|
readonly attachmentRemove: string
|
|
39
|
+
/** Estado de cada item da fila durante o envio (QR-47). */
|
|
40
|
+
readonly attachmentWaiting: string
|
|
41
|
+
readonly attachmentSending: string
|
|
42
|
+
readonly attachmentSent: string
|
|
43
|
+
readonly attachmentFailed: string
|
|
44
|
+
/** Servidor parou antes de tentar este arquivo, por causa de uma falha anterior no mesmo lote. */
|
|
45
|
+
readonly attachmentSkipped: string
|
|
46
|
+
readonly attachmentRetry: string
|
|
39
47
|
readonly recordFailure: string
|
|
40
48
|
readonly sendFailure: string
|
|
41
49
|
readonly takeoverToReply: string
|
|
@@ -76,6 +84,12 @@ export const DEFAULT_CONVERSATIONS_WORKSPACE_LABELS: ConversationsWorkspaceLabel
|
|
|
76
84
|
composerPlaceholder: 'Responder como atendente…',
|
|
77
85
|
attachFailure: 'Falha ao enviar o arquivo.',
|
|
78
86
|
attachmentRemove: 'Remover anexo',
|
|
87
|
+
attachmentWaiting: 'Aguardando',
|
|
88
|
+
attachmentSending: 'Enviando…',
|
|
89
|
+
attachmentSent: 'Enviado ✓',
|
|
90
|
+
attachmentFailed: 'Falhou',
|
|
91
|
+
attachmentSkipped: 'Não enviado — aguardando o anterior',
|
|
92
|
+
attachmentRetry: 'Tentar de novo',
|
|
79
93
|
recordFailure: 'Falha ao gravar o áudio.',
|
|
80
94
|
sendFailure: 'Falha ao enviar a mensagem.',
|
|
81
95
|
takeoverToReply: 'Assuma o atendimento para responder diretamente ao cliente.',
|