@adatechnology/conversations-ui 0.1.0-rc.9 → 0.1.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 +804 -0
- package/dist/{chunk-G5BM3VBP.js → chunk-BJNRLLDO.js} +1248 -282
- package/dist/chunk-DKPXKQGC.js +110 -0
- package/dist/{chunk-2AYDBWNE.js → chunk-WCBDXZ3X.js} +13 -3
- package/dist/flows/index.d.ts +422 -5
- package/dist/flows/index.js +2502 -676
- package/dist/index.d.ts +919 -17
- package/dist/index.js +3676 -675
- package/dist/preview/index.d.ts +62 -105
- package/dist/preview/index.js +162 -284
- package/dist/styles.css +893 -0
- package/package.json +9 -8
- package/src/AudioPlayer.tsx +8 -0
- package/src/AudioRecorderButton.test.tsx +30 -0
- package/src/AudioRecorderButton.tsx +248 -0
- package/src/AudioTranscription.test.tsx +115 -0
- package/src/AudioTranscription.tsx +252 -0
- package/src/Avatar.tsx +1 -1
- package/src/ConversationContextPanel.tsx +218 -44
- package/src/ConversationDocumentsPanel.tsx +11 -6
- package/src/ConversationHeader.test.tsx +66 -0
- package/src/ConversationHeader.tsx +147 -47
- package/src/ConversationListItem.tsx +8 -6
- package/src/ConversationLocalesProvider.tsx +28 -0
- package/src/ConversationRow.tsx +53 -7
- package/src/DarkModeToggle.test.tsx +76 -0
- package/src/DarkModeToggle.tsx +92 -0
- package/src/DocumentsLibrary.tsx +67 -7
- package/src/EmojiPicker.tsx +2 -1
- package/src/InteractiveMessage.tsx +3 -0
- package/src/Lightbox.tsx +1 -1
- package/src/MediaRenderer.tsx +88 -15
- package/src/MessageBubble.test.tsx +41 -0
- package/src/MessageBubble.tsx +47 -5
- package/src/MessageComposer.test.tsx +35 -0
- package/src/MessageComposer.tsx +122 -17
- package/src/MessageText.tsx +2 -1
- package/src/MessageTimestamp.tsx +2 -1
- package/src/RichMessageComposer.test.tsx +113 -0
- package/src/RichMessageComposer.tsx +551 -0
- package/src/SimpleEmojiPicker.tsx +5 -3
- package/src/StatusTicks.tsx +1 -1
- package/src/Toast.tsx +4 -0
- package/src/Tooltip.test.ts +42 -0
- package/src/Tooltip.tsx +167 -0
- package/src/Wallpaper.tsx +27 -13
- package/src/WhatsAppMessageEditor.tsx +10 -7
- package/src/WindowExpiredNotice.tsx +12 -4
- package/src/{preview/audioRecorderFormat.test.ts → audioRecorderFormat.test.ts} +1 -1
- package/src/buildOutput.test.ts +79 -0
- package/src/composer.constant.ts +33 -0
- package/src/conversationTranscript.test.ts +57 -0
- package/src/conversationTranscript.ts +29 -4
- package/src/conversationWindow.ts +7 -5
- package/src/documentTypeLabel.test.ts +57 -0
- package/src/documents/DocumentsWorkspace.tsx +550 -0
- package/src/documents/index.ts +8 -0
- package/src/documents/labels.ts +92 -0
- package/src/flows/FlowConnectionEdge.tsx +104 -0
- package/src/flows/FlowGroupHeader.tsx +12 -2
- package/src/flows/FlowLegend.tsx +125 -0
- package/src/flows/FlowMapCanvas.tsx +15 -12
- package/src/flows/FlowMapNode.tsx +4 -1
- package/src/flows/FlowNodeCard.tsx +219 -34
- package/src/flows/FlowNodePanel.tsx +153 -39
- package/src/flows/FlowPalette.tsx +156 -70
- package/src/flows/FlowPortalNode.tsx +1 -1
- package/src/flows/FlowWhatsAppPreview.tsx +14 -3
- package/src/flows/FlowsWorkspace.tsx +1255 -0
- package/src/flows/flowCanvasModel.test.ts +456 -0
- package/src/flows/flowCanvasModel.ts +378 -0
- package/src/flows/flowEditorOps.test.ts +276 -0
- package/src/flows/flowEditorOps.ts +202 -0
- package/src/flows/flowGraph.ts +78 -53
- package/src/flows/flowMenuPlacement.test.ts +130 -0
- package/src/flows/flowMenuPlacement.ts +86 -0
- package/src/flows/index.ts +51 -2
- package/src/flows/labels.ts +180 -0
- package/src/flows/workspaceContract.test.ts +126 -0
- package/src/hooks/useContainerWidth.ts +35 -0
- package/src/hooks/useConversationRealtime.ts +10 -8
- package/src/hooks/useScrollToLatestMessage.ts +127 -0
- package/src/hooks/useUrlFilterState.ts +107 -0
- package/src/icon.constant.ts +12 -0
- package/src/index.ts +100 -0
- package/src/lib/composer-formatting.test.ts +78 -0
- package/src/lib/composer-formatting.ts +145 -0
- package/src/lib/whatsapp-formatting.test.tsx +37 -0
- package/src/lib/whatsapp-formatting.tsx +28 -3
- package/src/listing/index.tsx +202 -0
- package/src/pagination.constant.ts +10 -0
- package/src/preview/ConversationPreview.tsx +84 -45
- package/src/preview/ConversationSimulatorClient.ts +143 -0
- package/src/preview/ConversationSimulatorPanel.test.tsx +55 -0
- package/src/preview/ConversationSimulatorPanel.tsx +131 -0
- package/src/preview/createPreviewBridgeClient.test.ts +92 -0
- package/src/preview/createPreviewBridgeClient.ts +124 -0
- package/src/preview/createPreviewMediaUploader.ts +82 -0
- package/src/preview/createPreviewWebhookClient.test.ts +96 -0
- package/src/preview/createPreviewWebhookClient.ts +99 -3
- package/src/preview/index.ts +36 -2
- package/src/preview/previewMediaUploader.test.ts +61 -0
- package/src/providers/ConversationsProvider.tsx +8 -6
- package/src/providers/types.ts +59 -2
- package/src/quickReply.test.ts +58 -0
- package/src/replyLatency.test.ts +71 -0
- package/src/replyLatency.ts +57 -0
- package/src/settings/MessagesWorkspace.tsx +571 -0
- package/src/settings/TopicsForm.tsx +2 -0
- package/src/settings/TranscriptionSettingsForm.test.tsx +81 -0
- package/src/settings/TranscriptionSettingsForm.tsx +190 -0
- package/src/settings/WelcomeFarewellForm.tsx +1 -0
- package/src/settings/WhatsAppCreateTemplateForm.tsx +1 -0
- package/src/settings/WhatsAppTemplateSettingsForm.tsx +5 -2
- package/src/settings/WhatsAppTemplatesSettings.test.tsx +61 -0
- package/src/settings/WhatsAppTemplatesSettings.tsx +22 -2
- package/src/styles.css +858 -0
- package/src/theme.ts +13 -0
- package/src/types.ts +26 -0
- package/src/workspace/BulkTemplateModal.tsx +132 -0
- package/src/workspace/ConversationPane.tsx +432 -0
- package/src/workspace/ConversationsInboxList.tsx +194 -0
- package/src/workspace/ConversationsWorkspace.tsx +423 -0
- package/src/workspace/index.ts +17 -0
- package/src/workspace/labels.test.ts +17 -0
- package/src/workspace/labels.ts +85 -0
- package/src/workspace/useConversationsInbox.ts +332 -0
- package/dist/types-B5C1DLu1.d.ts +0 -365
- package/src/preview/AudioRecorderButton.tsx +0 -117
package/src/theme.ts
CHANGED
|
@@ -28,3 +28,16 @@ export function getCustomStyles(theme?: ConversationsTheme): Record<string, stri
|
|
|
28
28
|
|
|
29
29
|
return styles
|
|
30
30
|
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Texto do chat nos dois temas.
|
|
34
|
+
*
|
|
35
|
+
* Existe porque as cores viviam cravadas em hexadecimal na marcação, sem variante escura: nome do
|
|
36
|
+
* cliente em `#111b21` e número, horário e prévia em `#667781` — os cinzas do WhatsApp CLARO. Com o
|
|
37
|
+
* tema escuro ligado eles continuavam escuros sobre fundo escuro, e a lista virava texto quase
|
|
38
|
+
* ilegível. O mesmo par aparecia dentro do balão, que também escurece.
|
|
39
|
+
*
|
|
40
|
+
* Os valores escuros são os que o próprio WhatsApp usa, para a tela não inventar uma paleta própria.
|
|
41
|
+
*/
|
|
42
|
+
export const CHAT_TEXT_PRIMARY_CLASS = 'text-[#111b21] dark:text-[#e9edef]'
|
|
43
|
+
export const CHAT_TEXT_SECONDARY_CLASS = 'text-[#667781] dark:text-[#8696a0]'
|
package/src/types.ts
CHANGED
|
@@ -84,6 +84,32 @@ export interface MessagePayload {
|
|
|
84
84
|
* diferente de avaliado e limpo.
|
|
85
85
|
*/
|
|
86
86
|
moderation?: { isOffensive: boolean; terms: string[] } | null
|
|
87
|
+
/**
|
|
88
|
+
* Transcrição do áudio, vinda do backend — a UI só exibe, nunca transcreve. Rodar STT no browser
|
|
89
|
+
* exigiria baixar modelo por aba e daria resultado diferente por versão de cliente.
|
|
90
|
+
*
|
|
91
|
+
* `null`/ausente = não avaliado, que é diferente de `'done'` com texto vazio (áudio em silêncio,
|
|
92
|
+
* já processado). É essa distinção que decide se o balão oferece "transcrever" ou "sem fala
|
|
93
|
+
* detectada".
|
|
94
|
+
*/
|
|
95
|
+
transcription?: MessageTranscription | null
|
|
87
96
|
isFirstInGroup?: boolean
|
|
88
97
|
isLastInGroup?: boolean
|
|
89
98
|
}
|
|
99
|
+
|
|
100
|
+
export type TranscriptionStatus = 'pending' | 'done' | 'failed' | 'unsupported'
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Quando transcrever, escolhido nas configurações da empresa. Espelha o
|
|
104
|
+
* `TranscriptionMode` de `@adatechnology/meta-whatsapp-contracts`; declarado aqui para o pacote de
|
|
105
|
+
* UI não obrigar quem só desenha telas a instalar os contratos do backend.
|
|
106
|
+
*/
|
|
107
|
+
export type TranscriptionMode = 'auto' | 'onDemand'
|
|
108
|
+
|
|
109
|
+
export interface MessageTranscription {
|
|
110
|
+
status: TranscriptionStatus
|
|
111
|
+
text?: string | null
|
|
112
|
+
/** ISO 639-1 ou nome do idioma, conforme o engine. Exibido como dica, não interpretado. */
|
|
113
|
+
language?: string | null
|
|
114
|
+
engine?: string | null
|
|
115
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Escolha do template para o envio em lote.
|
|
3
|
+
*
|
|
4
|
+
* Fora da janela de 24h só template abre conversa, e a lista aprovada muda por conta da Meta — daí
|
|
5
|
+
* a busca: quem tem trinta templates não acha o certo rolando. Só aparece quando o host implementa
|
|
6
|
+
* `listTemplates`; sem a listagem não há o que escolher, e o envio cai no template padrão.
|
|
7
|
+
*
|
|
8
|
+
* O aviso de "nenhuma selecionada está fora da janela" existe porque o envio parecia falhar em
|
|
9
|
+
* silêncio: dentro da janela a Meta recusa o template, e a tela não dizia por quê.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { useEffect, useMemo, useState } from 'react'
|
|
13
|
+
|
|
14
|
+
import { useConversations } from '../providers/ConversationsProvider'
|
|
15
|
+
import type { ConversationTemplate } from '../providers/types'
|
|
16
|
+
import type { ConversationsWorkspaceLabels } from './labels'
|
|
17
|
+
|
|
18
|
+
export interface BulkTemplateModalProps {
|
|
19
|
+
readonly labels: ConversationsWorkspaceLabels
|
|
20
|
+
/** Quantas das selecionadas estão fora da janela — as únicas que o template atinge. */
|
|
21
|
+
readonly expiredCount: number
|
|
22
|
+
readonly sending: boolean
|
|
23
|
+
readonly onClose: () => void
|
|
24
|
+
readonly onSend: (templateName: string | undefined) => void
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function BulkTemplateModal({
|
|
28
|
+
labels,
|
|
29
|
+
expiredCount,
|
|
30
|
+
sending,
|
|
31
|
+
onClose,
|
|
32
|
+
onSend,
|
|
33
|
+
}: BulkTemplateModalProps) {
|
|
34
|
+
const context = useConversations()
|
|
35
|
+
if (!context) {
|
|
36
|
+
throw new Error('BulkTemplateModal requires an ancestor <ConversationsProvider>')
|
|
37
|
+
}
|
|
38
|
+
const { api } = context
|
|
39
|
+
|
|
40
|
+
const [templates, setTemplates] = useState<readonly ConversationTemplate[]>([])
|
|
41
|
+
const [search, setSearch] = useState('')
|
|
42
|
+
const [selected, setSelected] = useState('')
|
|
43
|
+
|
|
44
|
+
useEffect(() => {
|
|
45
|
+
let active = true
|
|
46
|
+
void api
|
|
47
|
+
.listTemplates?.()
|
|
48
|
+
.then((loaded) => {
|
|
49
|
+
if (active) setTemplates(loaded)
|
|
50
|
+
})
|
|
51
|
+
.catch(() => {
|
|
52
|
+
// Lista vazia já comunica o que houve; um alerta a mais competiria com o atendimento.
|
|
53
|
+
})
|
|
54
|
+
return () => {
|
|
55
|
+
active = false
|
|
56
|
+
}
|
|
57
|
+
}, [api])
|
|
58
|
+
|
|
59
|
+
const filtered = useMemo(() => {
|
|
60
|
+
const term = search.trim().toLowerCase()
|
|
61
|
+
if (!term) return templates
|
|
62
|
+
return templates.filter((template) => template.name.toLowerCase().includes(term))
|
|
63
|
+
}, [templates, search])
|
|
64
|
+
|
|
65
|
+
return (
|
|
66
|
+
<div className="cv-workspace-modal" role="dialog" aria-modal="true" aria-label={labels.templateModalTitle}>
|
|
67
|
+
<div className="cv-workspace-modal__box">
|
|
68
|
+
<header className="cv-workspace-modal__header">
|
|
69
|
+
<div>
|
|
70
|
+
<h3>{labels.templateModalTitle}</h3>
|
|
71
|
+
<p>{labels.templateModalAvailable(templates.length)}</p>
|
|
72
|
+
</div>
|
|
73
|
+
<button data-cv-tooltip={labels.templateModalCancel} type="button" onClick={onClose} aria-label={labels.templateModalCancel}>
|
|
74
|
+
✕
|
|
75
|
+
</button>
|
|
76
|
+
</header>
|
|
77
|
+
|
|
78
|
+
<div className="cv-workspace-modal__search">
|
|
79
|
+
<input
|
|
80
|
+
type="search"
|
|
81
|
+
value={search}
|
|
82
|
+
onChange={(event) => setSearch(event.target.value)}
|
|
83
|
+
placeholder={labels.templateModalSearch}
|
|
84
|
+
autoFocus
|
|
85
|
+
/>
|
|
86
|
+
</div>
|
|
87
|
+
|
|
88
|
+
<div className="cv-workspace-modal__list">
|
|
89
|
+
{filtered.length === 0 ? <p className="cv-workspace-empty">{labels.templateModalEmpty}</p> : null}
|
|
90
|
+
{filtered.map((template) => (
|
|
91
|
+
<button
|
|
92
|
+
data-cv-tooltip={template.name} aria-label={template.name}
|
|
93
|
+
key={template.name}
|
|
94
|
+
type="button"
|
|
95
|
+
// Reclicar desmarca: sem isso não havia como voltar ao template padrão depois de
|
|
96
|
+
// escolher um por engano.
|
|
97
|
+
onClick={() => setSelected(template.name === selected ? '' : template.name)}
|
|
98
|
+
aria-pressed={template.name === selected}
|
|
99
|
+
className={`cv-workspace-modal__item${
|
|
100
|
+
template.name === selected ? ' cv-workspace-modal__item--on' : ''
|
|
101
|
+
}`}
|
|
102
|
+
>
|
|
103
|
+
<span className="cv-workspace-modal__item-name">{template.name}</span>
|
|
104
|
+
<span className="cv-workspace-modal__item-meta">
|
|
105
|
+
{template.language}
|
|
106
|
+
{template.category ? ` · ${template.category.toLowerCase()}` : ''}
|
|
107
|
+
</span>
|
|
108
|
+
</button>
|
|
109
|
+
))}
|
|
110
|
+
</div>
|
|
111
|
+
|
|
112
|
+
{expiredCount === 0 ? (
|
|
113
|
+
<p className="cv-workspace-modal__warning">{labels.templateModalNoneExpired}</p>
|
|
114
|
+
) : null}
|
|
115
|
+
|
|
116
|
+
<footer className="cv-workspace-modal__footer">
|
|
117
|
+
<button data-cv-tooltip={labels.templateModalCancel} aria-label={labels.templateModalCancel} type="button" onClick={onClose}>
|
|
118
|
+
{labels.templateModalCancel}
|
|
119
|
+
</button>
|
|
120
|
+
<button
|
|
121
|
+
data-cv-tooltip={sending ? labels.templateModalSending : labels.templateModalSend(expiredCount)} aria-label={sending ? labels.templateModalSending : labels.templateModalSend(expiredCount)}
|
|
122
|
+
type="button"
|
|
123
|
+
disabled={sending || expiredCount === 0}
|
|
124
|
+
onClick={() => onSend(selected || undefined)}
|
|
125
|
+
>
|
|
126
|
+
{sending ? labels.templateModalSending : labels.templateModalSend(expiredCount)}
|
|
127
|
+
</button>
|
|
128
|
+
</footer>
|
|
129
|
+
</div>
|
|
130
|
+
</div>
|
|
131
|
+
)
|
|
132
|
+
}
|
|
@@ -0,0 +1,432 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Coluna do meio: cabeçalho, contexto, documentos, transcript e composer da conversa aberta.
|
|
3
|
+
*
|
|
4
|
+
* Era a peça mais copiada entre os produtos, e a que mais divergia: um perdia o salto para a
|
|
5
|
+
* última mensagem, outro engolia falha de anexo, outro não abria a biblioteca de arquivos.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { useEffect, useRef, useState, type ReactNode } from 'react'
|
|
9
|
+
|
|
10
|
+
import { AudioRecorderButton } from '../AudioRecorderButton'
|
|
11
|
+
import { ConversationContextPanel, type ConversationContextEntry } from '../ConversationContextPanel'
|
|
12
|
+
import { ConversationDocumentsPanel } from '../ConversationDocumentsPanel'
|
|
13
|
+
import { ConversationHeader, type ConversationHeaderUtility } from '../ConversationHeader'
|
|
14
|
+
import { ConversationWallpaper } from '../Wallpaper'
|
|
15
|
+
import { DateDivider } from '../DateDivider'
|
|
16
|
+
import { MessageBubble } from '../MessageBubble'
|
|
17
|
+
import { MessageComposer, applyQuickReplyVariables, type QuickReply } from '../MessageComposer'
|
|
18
|
+
import { RichMessageComposer, type RichComposerVariable } from '../RichMessageComposer'
|
|
19
|
+
import { WindowExpiredNotice, isWindowBlocking } from '../WindowExpiredNotice'
|
|
20
|
+
import { windowOf } from '../conversationWindow'
|
|
21
|
+
import {
|
|
22
|
+
buildTranscriptFilename,
|
|
23
|
+
buildTranscriptText,
|
|
24
|
+
downloadTextFile,
|
|
25
|
+
} from '../conversationTranscript'
|
|
26
|
+
import { useConversationContext } from '../hooks/useConversationContext'
|
|
27
|
+
import { useConversationMessages } from '../hooks/useConversationMessages'
|
|
28
|
+
import { useConversationRealtime } from '../hooks/useConversationRealtime'
|
|
29
|
+
import { useScrollToLatestMessage } from '../hooks/useScrollToLatestMessage'
|
|
30
|
+
import { useConversations } from '../providers/ConversationsProvider'
|
|
31
|
+
import type { ConversationSummary } from '../providers/types'
|
|
32
|
+
import type { ConversationsWorkspaceLabels } from './labels'
|
|
33
|
+
|
|
34
|
+
export interface ConversationPaneProps {
|
|
35
|
+
readonly conversation: ConversationSummary
|
|
36
|
+
readonly now: number
|
|
37
|
+
readonly busy: boolean
|
|
38
|
+
readonly labels: ConversationsWorkspaceLabels
|
|
39
|
+
/** Devolve a promessa quando o host a tem: o envio de template espera a tomada antes de sair. */
|
|
40
|
+
readonly onTakeover?: (() => void | Promise<void>) | undefined
|
|
41
|
+
readonly onReturnToBot?: (() => void) | undefined
|
|
42
|
+
readonly onFinish?: (() => void) | undefined
|
|
43
|
+
readonly onBack: () => void
|
|
44
|
+
readonly extraUtilities?: readonly ConversationHeaderUtility[]
|
|
45
|
+
/** Traduz o contexto cru do produto nas linhas do painel. Ausente, o painel não aparece. */
|
|
46
|
+
readonly contextEntriesOf?: (context: Record<string, unknown> | undefined) => readonly ConversationContextEntry[]
|
|
47
|
+
readonly quickReplies?: readonly QuickReply[]
|
|
48
|
+
/**
|
|
49
|
+
* Recebe o contexto junto porque o dado que interessa à variável (o nome que o bot perguntou,
|
|
50
|
+
* por exemplo) vive no contexto do fluxo, não no resumo da listagem.
|
|
51
|
+
*/
|
|
52
|
+
readonly quickReplyVariablesFor?: (
|
|
53
|
+
conversation: ConversationSummary,
|
|
54
|
+
context: Record<string, unknown> | undefined,
|
|
55
|
+
) => Record<string, string>
|
|
56
|
+
/** Etapa do fluxo ao lado do contexto (ex.: "Anotando o pedido"). */
|
|
57
|
+
readonly flowLabel?: string | undefined
|
|
58
|
+
/**
|
|
59
|
+
* Substitui o download local do transcript. Existe para o produto que exporta pela rota do
|
|
60
|
+
* servidor, onde o arquivo sai completo em vez de só com o que a tela carregou.
|
|
61
|
+
*/
|
|
62
|
+
readonly onDownload?: (() => void) | undefined
|
|
63
|
+
/**
|
|
64
|
+
* Bloqueia o composer enquanto a conversa estiver com o bot. Ligado, responder sem assumir
|
|
65
|
+
* atropelaria o fluxo automático no meio de uma pergunta.
|
|
66
|
+
*/
|
|
67
|
+
readonly requireTakeoverToReply?: boolean
|
|
68
|
+
/**
|
|
69
|
+
* Deixa marcar mensagens e copiar o trecho. É o que se faz para levar um pedaço da conversa a um
|
|
70
|
+
* e-mail ou a um chamado, sem baixar o transcript inteiro.
|
|
71
|
+
*/
|
|
72
|
+
readonly messageSelection?: boolean
|
|
73
|
+
/** Texto já no campo ao abrir (deep link que sugere a resposta). */
|
|
74
|
+
readonly initialComposerText?: string | undefined
|
|
75
|
+
/**
|
|
76
|
+
* Peça do produto entre o contexto e o transcript (ex.: ficha do lead, resumo do pedido). Recebe o
|
|
77
|
+
* contexto do fluxo junto: é dele que sai o resumo, e sem isso o host teria de buscá-lo de novo.
|
|
78
|
+
*/
|
|
79
|
+
readonly renderAboveTranscript?: (
|
|
80
|
+
conversation: ConversationSummary,
|
|
81
|
+
context: Record<string, unknown> | undefined,
|
|
82
|
+
) => ReactNode
|
|
83
|
+
readonly onAttach?: ((file: File) => Promise<void>) | undefined
|
|
84
|
+
/**
|
|
85
|
+
* `rich` troca o `textarea` pelo campo com a formatação do WhatsApp desenhada enquanto se escreve.
|
|
86
|
+
* Vale onde o atendente manda texto longo e formatado; para responder "já vou ver" o campo simples
|
|
87
|
+
* é menos coisa na tela.
|
|
88
|
+
*/
|
|
89
|
+
readonly composer?: 'simple' | 'rich'
|
|
90
|
+
/** Valores que o operador insere sem digitar. Só o composer `rich` os oferece. */
|
|
91
|
+
readonly composerVariablesFor?: (
|
|
92
|
+
conversation: ConversationSummary,
|
|
93
|
+
context: Record<string, unknown> | undefined,
|
|
94
|
+
) => readonly RichComposerVariable[]
|
|
95
|
+
/**
|
|
96
|
+
* Fila de anexos com legenda, como no WhatsApp: os arquivos escolhidos ficam visíveis acima da
|
|
97
|
+
* barra e saem junto com o texto escrito. Ausente, o clipe manda cada arquivo na hora — o que
|
|
98
|
+
* perde a legenda, e era a diferença entre as telas.
|
|
99
|
+
*/
|
|
100
|
+
readonly onSendAttachments?: (files: readonly File[], caption: string) => Promise<void>
|
|
101
|
+
/** Grava e envia nota de voz. Ausente, o microfone não aparece. */
|
|
102
|
+
readonly onRecordAudio?: (file: File) => Promise<void>
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function ConversationPane({
|
|
106
|
+
conversation,
|
|
107
|
+
now,
|
|
108
|
+
busy,
|
|
109
|
+
labels,
|
|
110
|
+
onTakeover,
|
|
111
|
+
onReturnToBot,
|
|
112
|
+
onFinish,
|
|
113
|
+
onBack,
|
|
114
|
+
extraUtilities,
|
|
115
|
+
contextEntriesOf,
|
|
116
|
+
quickReplies,
|
|
117
|
+
quickReplyVariablesFor,
|
|
118
|
+
flowLabel,
|
|
119
|
+
onDownload,
|
|
120
|
+
requireTakeoverToReply,
|
|
121
|
+
messageSelection,
|
|
122
|
+
initialComposerText,
|
|
123
|
+
renderAboveTranscript,
|
|
124
|
+
onAttach,
|
|
125
|
+
composer = 'simple',
|
|
126
|
+
composerVariablesFor,
|
|
127
|
+
onSendAttachments,
|
|
128
|
+
onRecordAudio,
|
|
129
|
+
}: ConversationPaneProps) {
|
|
130
|
+
const context = useConversations()
|
|
131
|
+
if (!context) {
|
|
132
|
+
throw new Error('ConversationPane requires an ancestor <ConversationsProvider>')
|
|
133
|
+
}
|
|
134
|
+
const { api } = context
|
|
135
|
+
|
|
136
|
+
const { messages, refetch } = useConversationMessages(conversation.id)
|
|
137
|
+
const { context: conversationContext } = useConversationContext(conversation.id)
|
|
138
|
+
const [documentsOpen, setDocumentsOpen] = useState(false)
|
|
139
|
+
const [sendFailure, setSendFailure] = useState<string | undefined>(undefined)
|
|
140
|
+
const [selectedMessageIds, setSelectedMessageIds] = useState<ReadonlySet<string>>(new Set())
|
|
141
|
+
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
|
+
|
|
147
|
+
// Trocar de conversa zera as duas coisas: seleção de mensagem e rascunho pertencem à thread, e
|
|
148
|
+
// levá-los adiante faria copiar o trecho errado ou responder ao cliente errado.
|
|
149
|
+
useEffect(() => {
|
|
150
|
+
setSelectedMessageIds(new Set())
|
|
151
|
+
setDraft(initialComposerText ?? '')
|
|
152
|
+
setQueuedFiles([])
|
|
153
|
+
}, [conversation.id, initialComposerText])
|
|
154
|
+
|
|
155
|
+
function toggleMessageSelected(messageId: string): void {
|
|
156
|
+
setSelectedMessageIds((current) => {
|
|
157
|
+
const next = new Set(current)
|
|
158
|
+
if (next.has(messageId)) next.delete(messageId)
|
|
159
|
+
else next.add(messageId)
|
|
160
|
+
return next
|
|
161
|
+
})
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Data, quem falou e o texto: fora do painel o trecho precisa se sustentar sozinho. */
|
|
165
|
+
function copySelectedMessages(): void {
|
|
166
|
+
const transcript = messages
|
|
167
|
+
.filter((message) => selectedMessageIds.has(message.id))
|
|
168
|
+
.map(
|
|
169
|
+
(message) =>
|
|
170
|
+
`[${new Date(message.timestamp).toLocaleString()}] ${message.sender}: ${message.content ?? `(${message.type})`}`,
|
|
171
|
+
)
|
|
172
|
+
.join('\n')
|
|
173
|
+
void navigator.clipboard.writeText(transcript)
|
|
174
|
+
setSelectedMessageIds(new Set())
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Abrir no topo do histórico obrigava a rolar semanas até a última mensagem — que é sempre o que
|
|
178
|
+
// interessa. O hook salta ao trocar de conversa sem arrastar quem estiver lendo o histórico.
|
|
179
|
+
const scroll = useScrollToLatestMessage({ conversationId: conversation.id, messageCount: messages.length })
|
|
180
|
+
|
|
181
|
+
// O evento traz só `{ direction, sender }` — quem tem o conteúdo é a query.
|
|
182
|
+
useConversationRealtime(conversation.id, () => {
|
|
183
|
+
void refetch()
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
const blocked = isWindowBlocking(
|
|
187
|
+
windowOf({ lastInboundAt: conversation.lastInboundAt, now, channel: conversation.channel }),
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Falha vira aviso na tela em vez de exceção silenciosa: o atendente escreveu ou gravou, achou
|
|
192
|
+
* que mandou, e sem retorno não teria como saber que o cliente não recebeu nada.
|
|
193
|
+
*/
|
|
194
|
+
/** Devolve se o envio passou: limpar o rascunho depois de uma falha apagaria o texto do operador. */
|
|
195
|
+
async function runSend(action: () => Promise<unknown>, fallback: string): Promise<boolean> {
|
|
196
|
+
setSendFailure(undefined)
|
|
197
|
+
try {
|
|
198
|
+
await action()
|
|
199
|
+
await refetch()
|
|
200
|
+
return true
|
|
201
|
+
} catch (error: unknown) {
|
|
202
|
+
setSendFailure(error instanceof Error ? error.message : fallback)
|
|
203
|
+
return false
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
async function handleSend(text: string): Promise<boolean> {
|
|
208
|
+
return runSend(() => api.sendMessage(conversation.id, text), labels.sendFailure)
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Reabrir a janela é ato de atendente: quem manda o template quer conversar. A tomada vai antes do
|
|
213
|
+
* envio — se ficasse depois, a resposta do cliente chegaria com a conversa ainda no bot e o fluxo
|
|
214
|
+
* automático responderia por cima de quem acabou de reabri-la.
|
|
215
|
+
*/
|
|
216
|
+
async function handleSendTemplate(): Promise<void> {
|
|
217
|
+
if (conversation.mode !== 'human' && onTakeover) await onTakeover()
|
|
218
|
+
await runSend(() => api.sendTemplate(conversation.id, {}), labels.sendFailure)
|
|
219
|
+
}
|
|
220
|
+
|
|
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
|
+
async function handleAttach(file: File): Promise<void> {
|
|
251
|
+
if (!onAttach) return
|
|
252
|
+
await runSend(() => onAttach(file), labels.attachFailure)
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function handleDownload(): void {
|
|
256
|
+
downloadTextFile(
|
|
257
|
+
buildTranscriptFilename(conversation.whatsappNumber, new Date()),
|
|
258
|
+
buildTranscriptText({
|
|
259
|
+
messages,
|
|
260
|
+
whatsappNumber: conversation.whatsappNumber,
|
|
261
|
+
clientName: conversation.clientName,
|
|
262
|
+
}),
|
|
263
|
+
)
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const contextEntries = contextEntriesOf?.(conversationContext)
|
|
267
|
+
const composerVariables = composerVariablesFor?.(conversation, conversationContext)
|
|
268
|
+
/**
|
|
269
|
+
* As mesmas `quickReplies` do composer simples, com as variáveis já resolvidas — o campo rico
|
|
270
|
+
* recebe texto pronto. Uma segunda lista, só de formato diferente, é como as telas divergiam.
|
|
271
|
+
*/
|
|
272
|
+
const richQuickReplies = quickReplies?.map((reply) => ({
|
|
273
|
+
id: reply.key,
|
|
274
|
+
label: reply.label,
|
|
275
|
+
text:
|
|
276
|
+
typeof reply.text === 'string'
|
|
277
|
+
? applyQuickReplyVariables(reply.text, quickReplyVariablesFor?.(conversation, conversationContext) ?? {})
|
|
278
|
+
: reply.text(quickReplyVariablesFor?.(conversation, conversationContext) ?? {}),
|
|
279
|
+
}))
|
|
280
|
+
const botOwnsConversation = Boolean(requireTakeoverToReply) && conversation.mode !== 'human'
|
|
281
|
+
|
|
282
|
+
return (
|
|
283
|
+
<div className="cv-workspace-pane">
|
|
284
|
+
<ConversationHeader
|
|
285
|
+
conversation={conversation}
|
|
286
|
+
busy={busy}
|
|
287
|
+
{...(onTakeover ? { onTakeover } : {})}
|
|
288
|
+
{...(onReturnToBot ? { onReturnToBot } : {})}
|
|
289
|
+
{...(onFinish ? { onFinish } : {})}
|
|
290
|
+
onDownload={onDownload ?? handleDownload}
|
|
291
|
+
onBack={onBack}
|
|
292
|
+
onOpenDocuments={() => setDocumentsOpen(!documentsOpen)}
|
|
293
|
+
documentsOpen={documentsOpen}
|
|
294
|
+
{...(extraUtilities ? { extraUtilities } : {})}
|
|
295
|
+
/>
|
|
296
|
+
|
|
297
|
+
{(contextEntries && contextEntries.length > 0) || flowLabel ? (
|
|
298
|
+
<ConversationContextPanel entries={contextEntries ?? []} {...(flowLabel ? { flowLabel } : {})} />
|
|
299
|
+
) : null}
|
|
300
|
+
<ConversationDocumentsPanel conversationId={conversation.id} open={documentsOpen} />
|
|
301
|
+
|
|
302
|
+
{renderAboveTranscript?.(conversation, conversationContext)}
|
|
303
|
+
|
|
304
|
+
{/* Mesmo wallpaper do preview do cliente: atendente e cliente devem ver a conversa com a
|
|
305
|
+
mesma aparência, senão o preview deixa de ser referência confiável. */}
|
|
306
|
+
<ConversationWallpaper
|
|
307
|
+
ref={scroll.containerRef}
|
|
308
|
+
onScroll={scroll.handleScroll}
|
|
309
|
+
className="cv-workspace-transcript"
|
|
310
|
+
>
|
|
311
|
+
{messages.map((message, index) => {
|
|
312
|
+
const previous = index > 0 ? messages[index - 1] : undefined
|
|
313
|
+
const startsNewDay =
|
|
314
|
+
!previous || new Date(message.timestamp).toDateString() !== new Date(previous.timestamp).toDateString()
|
|
315
|
+
|
|
316
|
+
return (
|
|
317
|
+
<div key={message.id}>
|
|
318
|
+
{startsNewDay ? <DateDivider iso={message.timestamp} /> : null}
|
|
319
|
+
<MessageBubble
|
|
320
|
+
message={message}
|
|
321
|
+
isMine={message.direction === 'outbound'}
|
|
322
|
+
isFirstInGroup={!previous || previous.sender !== message.sender}
|
|
323
|
+
{...(messageSelection
|
|
324
|
+
? {
|
|
325
|
+
isSelecting: selectedMessageIds.size > 0,
|
|
326
|
+
isSelected: selectedMessageIds.has(message.id),
|
|
327
|
+
onToggleSelect: () => toggleMessageSelected(message.id),
|
|
328
|
+
}
|
|
329
|
+
: {})}
|
|
330
|
+
/>
|
|
331
|
+
</div>
|
|
332
|
+
)
|
|
333
|
+
})}
|
|
334
|
+
</ConversationWallpaper>
|
|
335
|
+
|
|
336
|
+
{messageSelection && selectedMessageIds.size > 0 ? (
|
|
337
|
+
<div className="cv-workspace-selection">
|
|
338
|
+
<span>{labels.messagesSelected(selectedMessageIds.size)}</span>
|
|
339
|
+
<div className="cv-workspace-selection__actions">
|
|
340
|
+
<button data-cv-tooltip={labels.bulkClear} aria-label={labels.bulkClear} type="button" onClick={() => setSelectedMessageIds(new Set())}>
|
|
341
|
+
{labels.bulkClear}
|
|
342
|
+
</button>
|
|
343
|
+
<button data-cv-tooltip={labels.copySelected} aria-label={labels.copySelected} type="button" onClick={copySelectedMessages}>
|
|
344
|
+
{labels.copySelected}
|
|
345
|
+
</button>
|
|
346
|
+
</div>
|
|
347
|
+
</div>
|
|
348
|
+
) : null}
|
|
349
|
+
|
|
350
|
+
{sendFailure ? (
|
|
351
|
+
<p role="alert" className="cv-workspace-alert">
|
|
352
|
+
{sendFailure}
|
|
353
|
+
</p>
|
|
354
|
+
) : null}
|
|
355
|
+
|
|
356
|
+
{blocked ? (
|
|
357
|
+
<WindowExpiredNotice
|
|
358
|
+
disabled={busy}
|
|
359
|
+
onSendTemplate={() => void handleSendTemplate()}
|
|
360
|
+
/>
|
|
361
|
+
) : botOwnsConversation ? (
|
|
362
|
+
// Responder com a conversa no bot atropelaria o fluxo automático no meio de uma pergunta.
|
|
363
|
+
<p className="cv-workspace-notice">{labels.takeoverToReply}</p>
|
|
364
|
+
) : composer === 'rich' ? (
|
|
365
|
+
<RichMessageComposer
|
|
366
|
+
value={draft}
|
|
367
|
+
onChange={setDraft}
|
|
368
|
+
onSend={() => void handleRichSend()}
|
|
369
|
+
{...(onSendAttachments
|
|
370
|
+
? { onAttachFiles: (files: FileList) => setQueuedFiles((current) => [...current, ...Array.from(files)]) }
|
|
371
|
+
: onAttach
|
|
372
|
+
? {
|
|
373
|
+
onAttachFiles: (files: FileList) => {
|
|
374
|
+
for (const file of Array.from(files)) void handleAttach(file)
|
|
375
|
+
},
|
|
376
|
+
}
|
|
377
|
+
: {})}
|
|
378
|
+
placeholder={labels.composerPlaceholder}
|
|
379
|
+
isSending={busy || isSendingDraft}
|
|
380
|
+
hasQueuedAttachments={queuedFiles.length > 0}
|
|
381
|
+
{...(onRecordAudio
|
|
382
|
+
? {
|
|
383
|
+
idleAction: (
|
|
384
|
+
<AudioRecorderButton
|
|
385
|
+
onRecorded={(file) => void runSend(() => onRecordAudio(file), labels.recordFailure)}
|
|
386
|
+
onFailure={(failureMessage) => setSendFailure(failureMessage)}
|
|
387
|
+
/>
|
|
388
|
+
),
|
|
389
|
+
}
|
|
390
|
+
: {})}
|
|
391
|
+
{...(queuedFiles.length > 0
|
|
392
|
+
? {
|
|
393
|
+
attachmentsPreview: (
|
|
394
|
+
<ul className="cv-workspace-attachments">
|
|
395
|
+
{queuedFiles.map((file, index) => (
|
|
396
|
+
<li key={`${file.name}-${index}`}>
|
|
397
|
+
<span>{file.name}</span>
|
|
398
|
+
<button
|
|
399
|
+
data-cv-tooltip={labels.attachmentRemove}
|
|
400
|
+
type="button"
|
|
401
|
+
aria-label={labels.attachmentRemove}
|
|
402
|
+
onClick={() =>
|
|
403
|
+
setQueuedFiles((current) => current.filter((_, position) => position !== index))
|
|
404
|
+
}
|
|
405
|
+
>
|
|
406
|
+
✕
|
|
407
|
+
</button>
|
|
408
|
+
</li>
|
|
409
|
+
))}
|
|
410
|
+
</ul>
|
|
411
|
+
),
|
|
412
|
+
}
|
|
413
|
+
: {})}
|
|
414
|
+
{...(richQuickReplies ? { quickReplies: [...richQuickReplies] } : {})}
|
|
415
|
+
{...(composerVariables ? { variables: [...composerVariables] } : {})}
|
|
416
|
+
/>
|
|
417
|
+
) : (
|
|
418
|
+
<MessageComposer
|
|
419
|
+
value={draft}
|
|
420
|
+
onChange={setDraft}
|
|
421
|
+
onSend={(text) => void handleSend(text)}
|
|
422
|
+
// Habilita clipe E microfone: o composer desenha o gravador sozinho quando existe um jeito
|
|
423
|
+
// de entregar arquivo, porque áudio gravado é um anexo como qualquer outro.
|
|
424
|
+
{...(onAttach ? { onAttach: (file: File) => void handleAttach(file) } : {})}
|
|
425
|
+
placeholder={labels.composerPlaceholder}
|
|
426
|
+
{...(quickReplies ? { quickReplies } : {})}
|
|
427
|
+
{...(quickReplyVariablesFor ? { quickReplyVariables: quickReplyVariablesFor(conversation, conversationContext) } : {})}
|
|
428
|
+
/>
|
|
429
|
+
)}
|
|
430
|
+
</div>
|
|
431
|
+
)
|
|
432
|
+
}
|