@adatechnology/conversations-ui 0.1.1 → 0.2.0
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 +2166 -509
- 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 +396 -0
- package/src/quickReplies/quickReplyAttachments.ts +289 -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 +155 -0
- package/src/workspace/useComposerQueue.ts +220 -0
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fila de upload de anexo do formulário de cadastro (QR-31/M1), extraída de
|
|
3
|
+
* `useQuickRepliesWorkspace`: até 3 uploads em voo por vez, progresso real por arquivo, e
|
|
4
|
+
* cancelamento de tudo ao trocar de registro (M2) sem `setState` órfão depois do unmount.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { useCallback, useEffect, useRef, useState } from 'react'
|
|
8
|
+
import { createUploadQueue } from './createUploadQueue'
|
|
9
|
+
import { validateAttachmentFiles, type AttachmentFileRejection } from './quickReplyAttachmentUpload'
|
|
10
|
+
import type { MaxAttachmentSizeBytes } from './quickReplyAttachments'
|
|
11
|
+
import type { QuickReplyAttachment } from './quickReply.types'
|
|
12
|
+
import type { QuickRepliesWorkspaceLabels } from './labels'
|
|
13
|
+
|
|
14
|
+
/** Item em upload no formulário: estado local, nunca persistido — some ao terminar ou ser removido. */
|
|
15
|
+
export type PendingAttachmentUpload = {
|
|
16
|
+
readonly localId: string
|
|
17
|
+
readonly file: File
|
|
18
|
+
readonly status: 'uploading' | 'error'
|
|
19
|
+
readonly progress: number
|
|
20
|
+
readonly error?: string
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export type UseQuickReplyAttachmentUploadsParams = {
|
|
24
|
+
readonly upload?: (
|
|
25
|
+
file: File,
|
|
26
|
+
options?: { onProgress?: (fraction: number) => void; signal?: AbortSignal },
|
|
27
|
+
) => Promise<QuickReplyAttachment>
|
|
28
|
+
readonly labels: QuickRepliesWorkspaceLabels
|
|
29
|
+
/** Sobrescreve o teto por tipo de arquivo. Ausente, usa `DEFAULT_MAX_ATTACHMENT_SIZE_BYTES`. */
|
|
30
|
+
readonly attachmentSizeLimits?: MaxAttachmentSizeBytes
|
|
31
|
+
/** Quantos anexos já confirmados (`editing.attachments.length`) — entra no teto de 10 (QR-31). */
|
|
32
|
+
readonly attachmentsCount: number
|
|
33
|
+
/** Onde o item entra assim que o upload resolve — o hook não sabe de `editing`, só devolve o resultado. */
|
|
34
|
+
readonly onUploaded: (attachment: QuickReplyAttachment) => void
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export type UseQuickReplyAttachmentUploadsResult = {
|
|
38
|
+
readonly pendingUploads: readonly PendingAttachmentUpload[]
|
|
39
|
+
readonly attachmentRejections: readonly AttachmentFileRejection[]
|
|
40
|
+
readonly addAttachmentFiles: (files: FileList | readonly File[]) => void
|
|
41
|
+
readonly retryAttachmentUpload: (localId: string) => void
|
|
42
|
+
readonly cancelAttachmentUpload: (localId: string) => void
|
|
43
|
+
readonly dismissAttachmentRejections: () => void
|
|
44
|
+
/** Cancela todo upload em voo — trocar de registro sem isso deixaria um `fetch` órfão terminando
|
|
45
|
+
* sozinho e tentando atualizar um estado que já não existe mais. */
|
|
46
|
+
readonly abortAllUploads: () => void
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Estado e orquestração do upload de anexo — sem saber de `editing`, só de arquivos e resultado. */
|
|
50
|
+
export function useQuickReplyAttachmentUploads({
|
|
51
|
+
upload,
|
|
52
|
+
labels,
|
|
53
|
+
attachmentSizeLimits,
|
|
54
|
+
attachmentsCount,
|
|
55
|
+
onUploaded,
|
|
56
|
+
}: UseQuickReplyAttachmentUploadsParams): UseQuickReplyAttachmentUploadsResult {
|
|
57
|
+
const [pendingUploads, setPendingUploads] = useState<readonly PendingAttachmentUpload[]>([])
|
|
58
|
+
const [attachmentRejections, setAttachmentRejections] = useState<readonly AttachmentFileRejection[]>([])
|
|
59
|
+
/** Fila compartilhada (M1): no máximo 3 uploads em voo ao mesmo tempo, somando o que
|
|
60
|
+
* `addAttachmentFiles` e `retryAttachmentUpload` enfileiram — nenhum dos dois abre janela própria. */
|
|
61
|
+
const uploadQueueRef = useRef(createUploadQueue(3))
|
|
62
|
+
/** M2: depois do unmount, nenhuma promessa de upload em voo pode chamar setState — só aborta. */
|
|
63
|
+
const isMountedRef = useRef(true)
|
|
64
|
+
|
|
65
|
+
useEffect(
|
|
66
|
+
() => () => {
|
|
67
|
+
isMountedRef.current = false
|
|
68
|
+
uploadQueueRef.current.abortAll()
|
|
69
|
+
},
|
|
70
|
+
[],
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
const abortAllUploads = useCallback(() => {
|
|
74
|
+
uploadQueueRef.current.abortAll()
|
|
75
|
+
setPendingUploads([])
|
|
76
|
+
}, [])
|
|
77
|
+
|
|
78
|
+
const dismissAttachmentRejections = useCallback(() => setAttachmentRejections([]), [])
|
|
79
|
+
|
|
80
|
+
const cancelAttachmentUpload = useCallback((localId: string) => {
|
|
81
|
+
uploadQueueRef.current.abort(localId)
|
|
82
|
+
setPendingUploads((current) => current.filter((item) => item.localId !== localId))
|
|
83
|
+
}, [])
|
|
84
|
+
|
|
85
|
+
const updatePendingUpload = useCallback((localId: string, patch: Partial<PendingAttachmentUpload>) => {
|
|
86
|
+
setPendingUploads((current) => current.map((item) => (item.localId === localId ? { ...item, ...patch } : item)))
|
|
87
|
+
}, [])
|
|
88
|
+
|
|
89
|
+
/** Sobe um arquivo já validado, através da fila compartilhada (M1): progresso real por
|
|
90
|
+
* `onProgress`, e o resultado vira `onUploaded` na ordem de chegada assim que a promessa
|
|
91
|
+
* resolve. Enfileira e retorna — quem chama não espera. */
|
|
92
|
+
const uploadOneFile = useCallback(
|
|
93
|
+
(localId: string, file: File) => {
|
|
94
|
+
if (!upload) return
|
|
95
|
+
uploadQueueRef.current.enqueue(localId, async (signal) => {
|
|
96
|
+
try {
|
|
97
|
+
const attachment = await upload(file, {
|
|
98
|
+
onProgress: (fraction) => {
|
|
99
|
+
if (isMountedRef.current) updatePendingUpload(localId, { progress: fraction })
|
|
100
|
+
},
|
|
101
|
+
signal,
|
|
102
|
+
})
|
|
103
|
+
if (!isMountedRef.current) return
|
|
104
|
+
onUploaded(attachment)
|
|
105
|
+
setPendingUploads((current) => current.filter((item) => item.localId !== localId))
|
|
106
|
+
} catch (caught: unknown) {
|
|
107
|
+
if (signal.aborted || !isMountedRef.current) return
|
|
108
|
+
updatePendingUpload(localId, {
|
|
109
|
+
status: 'error',
|
|
110
|
+
error: caught instanceof Error ? caught.message : labels.saveError,
|
|
111
|
+
})
|
|
112
|
+
}
|
|
113
|
+
})
|
|
114
|
+
},
|
|
115
|
+
[upload, updatePendingUpload, onUploaded, labels.saveError],
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
const retryAttachmentUpload = useCallback(
|
|
119
|
+
(localId: string) => {
|
|
120
|
+
const item = pendingUploads.find((pending) => pending.localId === localId)
|
|
121
|
+
if (!item) return
|
|
122
|
+
updatePendingUpload(localId, { status: 'uploading', progress: 0, error: undefined })
|
|
123
|
+
uploadOneFile(localId, item.file)
|
|
124
|
+
},
|
|
125
|
+
[pendingUploads, updatePendingUpload, uploadOneFile],
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
/** Valida (teto de 10, tamanho por tipo) ANTES de subir (QR-31) — só o aceito vira upload; o
|
|
129
|
+
* recusado fica em `attachmentRejections` para a tela explicar por quê, sem gastar rede nele. */
|
|
130
|
+
const addAttachmentFiles = useCallback(
|
|
131
|
+
(files: FileList | readonly File[]) => {
|
|
132
|
+
if (!upload) return
|
|
133
|
+
const currentCount = attachmentsCount + pendingUploads.length
|
|
134
|
+
const { accepted, rejected } = validateAttachmentFiles(Array.from(files), currentCount, attachmentSizeLimits)
|
|
135
|
+
setAttachmentRejections(rejected)
|
|
136
|
+
if (accepted.length === 0) return
|
|
137
|
+
const newItems: PendingAttachmentUpload[] = accepted.map((file) => ({
|
|
138
|
+
localId: `${file.name}-${file.size}-${file.lastModified}-${Math.random().toString(36).slice(2)}`,
|
|
139
|
+
file,
|
|
140
|
+
status: 'uploading',
|
|
141
|
+
progress: 0,
|
|
142
|
+
}))
|
|
143
|
+
setPendingUploads((current) => [...current, ...newItems])
|
|
144
|
+
// Até 3 em paralelo (QR-31), somando com retries em voo — a fila compartilhada decide (M1).
|
|
145
|
+
for (const item of newItems) uploadOneFile(item.localId, item.file)
|
|
146
|
+
},
|
|
147
|
+
[upload, attachmentsCount, pendingUploads.length, uploadOneFile, attachmentSizeLimits],
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
return {
|
|
151
|
+
pendingUploads,
|
|
152
|
+
attachmentRejections,
|
|
153
|
+
addAttachmentFiles,
|
|
154
|
+
retryAttachmentUpload,
|
|
155
|
+
cancelAttachmentUpload,
|
|
156
|
+
dismissAttachmentRejections,
|
|
157
|
+
abortAllUploads,
|
|
158
|
+
}
|
|
159
|
+
}
|
package/src/styles.css
CHANGED
|
@@ -818,6 +818,93 @@
|
|
|
818
818
|
border-color: rgb(51 65 85);
|
|
819
819
|
}
|
|
820
820
|
|
|
821
|
+
/* Item da fila com miniatura, estado de envio (QR-47) e saída com transição curta. */
|
|
822
|
+
.cv-attachment-item {
|
|
823
|
+
max-width: 16rem;
|
|
824
|
+
}
|
|
825
|
+
.cv-attachment-item__thumbnail {
|
|
826
|
+
display: block;
|
|
827
|
+
flex: none;
|
|
828
|
+
width: 2rem;
|
|
829
|
+
height: 2rem;
|
|
830
|
+
overflow: hidden;
|
|
831
|
+
border-radius: 0.375rem;
|
|
832
|
+
background: rgb(226 232 240);
|
|
833
|
+
}
|
|
834
|
+
.dark .cv-attachment-item__thumbnail { background: rgb(51 65 85); }
|
|
835
|
+
.cv-attachment-item__thumbnail img { width: 100%; height: 100%; object-fit: cover; }
|
|
836
|
+
.cv-attachment-item__info {
|
|
837
|
+
display: flex;
|
|
838
|
+
flex-direction: column;
|
|
839
|
+
overflow: hidden;
|
|
840
|
+
min-width: 0;
|
|
841
|
+
}
|
|
842
|
+
.cv-attachment-item__name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 500; }
|
|
843
|
+
.cv-attachment-item__meta { font-size: 0.6875rem; color: rgb(100 116 139); }
|
|
844
|
+
.dark .cv-attachment-item__meta { color: rgb(148 163 184); }
|
|
845
|
+
.cv-attachment-item--failed .cv-attachment-item__meta { color: rgb(220 38 38); }
|
|
846
|
+
.dark .cv-attachment-item--failed .cv-attachment-item__meta { color: rgb(248 113 113); }
|
|
847
|
+
.cv-attachment-item--sent .cv-attachment-item__meta { color: rgb(22 163 74); }
|
|
848
|
+
.dark .cv-attachment-item--sent .cv-attachment-item__meta { color: rgb(74 222 128); }
|
|
849
|
+
.cv-attachment-item--skipped .cv-attachment-item__meta { color: rgb(180 83 9); }
|
|
850
|
+
.dark .cv-attachment-item--skipped .cv-attachment-item__meta { color: rgb(251 191 36); }
|
|
851
|
+
.cv-attachment-item__retry {
|
|
852
|
+
flex: none;
|
|
853
|
+
border: 0;
|
|
854
|
+
background: none;
|
|
855
|
+
padding: 0;
|
|
856
|
+
font-size: 0.6875rem;
|
|
857
|
+
text-decoration: underline;
|
|
858
|
+
color: rgb(37 99 235);
|
|
859
|
+
cursor: pointer;
|
|
860
|
+
}
|
|
861
|
+
.cv-attachment-item {
|
|
862
|
+
transition: opacity 200ms ease, transform 200ms ease;
|
|
863
|
+
opacity: 1;
|
|
864
|
+
transform: scale(1);
|
|
865
|
+
}
|
|
866
|
+
.cv-attachment-item--departing {
|
|
867
|
+
opacity: 0;
|
|
868
|
+
transform: scale(0.92);
|
|
869
|
+
}
|
|
870
|
+
@media (prefers-reduced-motion: reduce) {
|
|
871
|
+
.cv-attachment-item { transition: none; }
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
/* Esqueleto de linha — usado no painel de mensagens prontas e na tabela de cadastro (QR-47): nunca
|
|
875
|
+
"Carregando…" solto, e o conteúdo nunca aparece de uma vez. */
|
|
876
|
+
.cv-skeleton-line {
|
|
877
|
+
border-radius: 0.25rem;
|
|
878
|
+
background: linear-gradient(90deg, rgb(226 232 240) 25%, rgb(241 245 249) 50%, rgb(226 232 240) 75%);
|
|
879
|
+
background-size: 200% 100%;
|
|
880
|
+
animation: cv-skeleton-shimmer 1.4s ease-in-out infinite;
|
|
881
|
+
}
|
|
882
|
+
.dark .cv-skeleton-line {
|
|
883
|
+
background: linear-gradient(90deg, rgb(51 65 85) 25%, rgb(71 85 105) 50%, rgb(51 65 85) 75%);
|
|
884
|
+
background-size: 200% 100%;
|
|
885
|
+
}
|
|
886
|
+
@keyframes cv-skeleton-shimmer {
|
|
887
|
+
0% { background-position: 200% 0; }
|
|
888
|
+
100% { background-position: -200% 0; }
|
|
889
|
+
}
|
|
890
|
+
@media (prefers-reduced-motion: reduce) {
|
|
891
|
+
.cv-skeleton-line { animation: none; }
|
|
892
|
+
}
|
|
893
|
+
/* Larguras da linha de esqueleto da tabela de mensagens prontas: uma por coluna, porque colunas
|
|
894
|
+
diferentes leem melhor com proporções diferentes em vez de todas do mesmo tamanho. */
|
|
895
|
+
.cv-skeleton-line--title { width: 70%; height: 0.75rem; }
|
|
896
|
+
.cv-skeleton-line--shortcut { width: 50%; height: 0.75rem; }
|
|
897
|
+
.cv-skeleton-line--body { width: 90%; height: 0.75rem; }
|
|
898
|
+
|
|
899
|
+
/* Linha excluída sai com transição curta em vez de sumir de repente (QR-47). */
|
|
900
|
+
.cv-row-departing {
|
|
901
|
+
transition: opacity 200ms ease;
|
|
902
|
+
opacity: 0;
|
|
903
|
+
}
|
|
904
|
+
@media (prefers-reduced-motion: reduce) {
|
|
905
|
+
.cv-row-departing { transition: none; }
|
|
906
|
+
}
|
|
907
|
+
|
|
821
908
|
/* Peças de listagem (documentos, mensagens): tabela com filtros, seleção em lote e paginação. */
|
|
822
909
|
.cv-filter-count {
|
|
823
910
|
display: inline-flex;
|
|
@@ -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
|
+
})
|