@adatechnology/conversations-ui 0.1.0-rc.9 → 0.1.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 +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
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Textos da inbox. Todos sobrescrevíveis: o produto troca vocabulário ("cliente" vira "lead") ou
|
|
3
|
+
* o idioma inteiro sem tocar no layout — e sem manter uma cópia da tela para isso.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export interface ConversationsWorkspaceLabels {
|
|
7
|
+
readonly title: string
|
|
8
|
+
readonly conversations: string
|
|
9
|
+
readonly waiting: string
|
|
10
|
+
readonly unread: string
|
|
11
|
+
readonly waitingOnly: string
|
|
12
|
+
readonly markSelectedAsRead: string
|
|
13
|
+
readonly search: string
|
|
14
|
+
readonly windowLegend: string
|
|
15
|
+
readonly channelLegend: string
|
|
16
|
+
readonly selectAll: string
|
|
17
|
+
readonly emptyList: string
|
|
18
|
+
readonly emptyDetail: string
|
|
19
|
+
readonly bulkSelected: (count: number) => string
|
|
20
|
+
readonly bulkClear: string
|
|
21
|
+
readonly bulkFinalize: string
|
|
22
|
+
readonly bulkFinalizeConfirm: (count: number) => string
|
|
23
|
+
readonly bulkTemplate: string
|
|
24
|
+
readonly markAllAsRead: string
|
|
25
|
+
readonly templateModalTitle: string
|
|
26
|
+
readonly templateModalSearch: string
|
|
27
|
+
readonly templateModalEmpty: string
|
|
28
|
+
readonly templateModalCancel: string
|
|
29
|
+
readonly templateModalSending: string
|
|
30
|
+
readonly templateModalNoneExpired: string
|
|
31
|
+
readonly templateModalAvailable: (count: number) => string
|
|
32
|
+
readonly templateModalSend: (count: number) => string
|
|
33
|
+
readonly messagesSelected: (count: number) => string
|
|
34
|
+
readonly copySelected: string
|
|
35
|
+
readonly composerPlaceholder: string
|
|
36
|
+
readonly attachFailure: string
|
|
37
|
+
/** Tira um arquivo da fila antes de enviar. */
|
|
38
|
+
readonly attachmentRemove: string
|
|
39
|
+
readonly recordFailure: string
|
|
40
|
+
readonly sendFailure: string
|
|
41
|
+
readonly takeoverToReply: string
|
|
42
|
+
readonly signIn: string
|
|
43
|
+
readonly pageOf: (page: number, pageCount: number) => string
|
|
44
|
+
readonly rangeOf: (first: number, last: number, total: number) => string
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export const DEFAULT_CONVERSATIONS_WORKSPACE_LABELS: ConversationsWorkspaceLabels = {
|
|
48
|
+
title: 'Conversas',
|
|
49
|
+
conversations: 'conversas',
|
|
50
|
+
waiting: 'aguardando',
|
|
51
|
+
unread: 'não lidas',
|
|
52
|
+
waitingOnly: 'Aguardando atendimento',
|
|
53
|
+
markSelectedAsRead: 'Marcar selecionadas como lidas',
|
|
54
|
+
search: 'Buscar conversa...',
|
|
55
|
+
windowLegend: 'Janela:',
|
|
56
|
+
channelLegend: 'Canal:',
|
|
57
|
+
selectAll: 'Selecionar todas',
|
|
58
|
+
emptyList: 'Nenhuma conversa encontrada.',
|
|
59
|
+
emptyDetail: 'Selecione uma conversa.',
|
|
60
|
+
bulkSelected: (count) => `${count} selecionada${count === 1 ? '' : 's'}`,
|
|
61
|
+
bulkClear: 'Limpar seleção',
|
|
62
|
+
bulkFinalize: 'Finalizar',
|
|
63
|
+
bulkFinalizeConfirm: (count) => `Finalizar ${count} conversa${count === 1 ? '' : 's'}?`,
|
|
64
|
+
bulkTemplate: 'Enviar template',
|
|
65
|
+
markAllAsRead: 'Marcar todas como lidas',
|
|
66
|
+
templateModalTitle: 'Escolher template',
|
|
67
|
+
templateModalSearch: 'Buscar template...',
|
|
68
|
+
templateModalEmpty: 'Nenhum template encontrado.',
|
|
69
|
+
templateModalCancel: 'Cancelar',
|
|
70
|
+
templateModalSending: 'Enviando…',
|
|
71
|
+
templateModalNoneExpired: 'Nenhuma das conversas selecionadas está fora da janela de 24h.',
|
|
72
|
+
templateModalAvailable: (count) => `${count} template${count === 1 ? '' : 's'} disponíve${count === 1 ? 'l' : 'is'}`,
|
|
73
|
+
templateModalSend: (count) => `Enviar para ${count}`,
|
|
74
|
+
messagesSelected: (count) => `${count} mensagem${count === 1 ? '' : 's'} selecionada${count === 1 ? '' : 's'}`,
|
|
75
|
+
copySelected: 'Copiar',
|
|
76
|
+
composerPlaceholder: 'Responder como atendente…',
|
|
77
|
+
attachFailure: 'Falha ao enviar o arquivo.',
|
|
78
|
+
attachmentRemove: 'Remover anexo',
|
|
79
|
+
recordFailure: 'Falha ao gravar o áudio.',
|
|
80
|
+
sendFailure: 'Falha ao enviar a mensagem.',
|
|
81
|
+
takeoverToReply: 'Assuma o atendimento para responder diretamente ao cliente.',
|
|
82
|
+
signIn: 'Entrar no painel',
|
|
83
|
+
pageOf: (page, pageCount) => `${page} / ${pageCount}`,
|
|
84
|
+
rangeOf: (first, last, total) => (total === 0 ? `0 de 0` : `${first}–${last} de ${total}`),
|
|
85
|
+
}
|
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Estado da inbox: filtros, paginação, seleção em massa e ações de atendimento.
|
|
3
|
+
*
|
|
4
|
+
* Nasceu igual em três produtos e divergiu em três — contadores diferentes, um marcava lida ao
|
|
5
|
+
* abrir e outro não, um perdia a página ao trocar de filtro. É a lógica de operar uma inbox, não
|
|
6
|
+
* regra de nenhum negócio, e por isso mora aqui.
|
|
7
|
+
*
|
|
8
|
+
* A paginação é do cliente porque nem todo backend pagina a listagem. Fatiar aqui mantém a tela
|
|
9
|
+
* utilizável com centenas de conversas; com dezenas de milhares o gargalo volta e a resposta é
|
|
10
|
+
* paginar no servidor.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|
14
|
+
|
|
15
|
+
import { useConversationList } from '../hooks/useConversationList'
|
|
16
|
+
import { useGlobalRealtime } from '../hooks/useConversationRealtime'
|
|
17
|
+
import { useConversations } from '../providers/ConversationsProvider'
|
|
18
|
+
import { CONVERSATION_WINDOW, windowOf, type ConversationWindow } from '../conversationWindow'
|
|
19
|
+
import { isWindowBlocking } from '../WindowExpiredNotice'
|
|
20
|
+
import {
|
|
21
|
+
CHANNEL_FILTER_ALL,
|
|
22
|
+
DEFAULT_CONVERSATION_CHANNEL,
|
|
23
|
+
channelFiltersFor,
|
|
24
|
+
type ChannelFilter,
|
|
25
|
+
type ChannelFilterOption,
|
|
26
|
+
} from '../conversationChannel'
|
|
27
|
+
import type { ConversationSummary } from '../providers/types'
|
|
28
|
+
|
|
29
|
+
export const CONVERSATIONS_PER_PAGE = 50
|
|
30
|
+
|
|
31
|
+
export interface UseConversationsInboxParams {
|
|
32
|
+
/** Recortes do produto repassados crus ao backend dele (carteira, campanha, tipo). */
|
|
33
|
+
readonly filters?: Record<string, string | undefined>
|
|
34
|
+
readonly perPage?: number
|
|
35
|
+
/**
|
|
36
|
+
* Marca como lida a conversa aberta. Ligado por padrão: badge subindo na conversa que o atendente
|
|
37
|
+
* tem na frente é ruído que ele não tem como resolver.
|
|
38
|
+
*/
|
|
39
|
+
readonly markReadOnOpen?: boolean
|
|
40
|
+
/**
|
|
41
|
+
* Pede a página ao servidor em vez de fatiar o que veio. Necessário quando a base é grande — o
|
|
42
|
+
* padrão traz tudo e pagina no cliente porque nem todo backend pagina a listagem.
|
|
43
|
+
*/
|
|
44
|
+
readonly serverPaginated?: boolean
|
|
45
|
+
readonly describeFailure?: (error: unknown) => string
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface UseConversationsInboxResult {
|
|
49
|
+
readonly conversations: readonly ConversationSummary[]
|
|
50
|
+
readonly pageConversations: readonly ConversationSummary[]
|
|
51
|
+
readonly selectedConversation: ConversationSummary | undefined
|
|
52
|
+
readonly loading: boolean
|
|
53
|
+
readonly now: number
|
|
54
|
+
readonly totalCount: number
|
|
55
|
+
readonly unreadCount: number
|
|
56
|
+
readonly waitingCount: number
|
|
57
|
+
readonly filteredCount: number
|
|
58
|
+
/** Selecionadas fora da janela de 24h — as únicas que um template alcança. */
|
|
59
|
+
readonly expiredSelectedCount: number
|
|
60
|
+
readonly page: number
|
|
61
|
+
readonly pageCount: number
|
|
62
|
+
readonly firstOnPage: number
|
|
63
|
+
readonly lastOnPage: number
|
|
64
|
+
readonly selectedId: string | undefined
|
|
65
|
+
readonly selectedIds: ReadonlySet<string>
|
|
66
|
+
readonly allOnPageSelected: boolean
|
|
67
|
+
readonly waitingOnly: boolean
|
|
68
|
+
readonly windowFilter: ConversationWindow
|
|
69
|
+
readonly channelFilter: ChannelFilter
|
|
70
|
+
readonly channelFilters: readonly ChannelFilterOption[]
|
|
71
|
+
readonly search: string
|
|
72
|
+
readonly busy: boolean
|
|
73
|
+
/** Por que a lista está vazia, quando não é por não haver conversa. */
|
|
74
|
+
readonly loadFailure: string | undefined
|
|
75
|
+
/** Ausentes quando o host não implementa a capacidade — a UI não desenha a afordância. */
|
|
76
|
+
readonly canTakeover: boolean
|
|
77
|
+
readonly canFinalize: boolean
|
|
78
|
+
readonly canListTemplates: boolean
|
|
79
|
+
readonly canMarkAllRead: boolean
|
|
80
|
+
refetch(): Promise<void> | void
|
|
81
|
+
selectConversation(conversationId: string): void
|
|
82
|
+
clearSelection(): void
|
|
83
|
+
toggleSelected(conversationId: string): void
|
|
84
|
+
toggleSelectAllOnPage(): void
|
|
85
|
+
clearBulkSelection(): void
|
|
86
|
+
setWaitingOnly(value: boolean): void
|
|
87
|
+
setWindowFilter(value: ConversationWindow): void
|
|
88
|
+
setChannelFilter(value: ChannelFilter): void
|
|
89
|
+
setSearch(value: string): void
|
|
90
|
+
goToPage(value: number): void
|
|
91
|
+
markSelectedAsRead(): Promise<void>
|
|
92
|
+
markAllAsRead(): Promise<void>
|
|
93
|
+
takeover(conversationId: string): Promise<void>
|
|
94
|
+
releaseToBot(conversationId: string): Promise<void>
|
|
95
|
+
finalize(conversationId: string): Promise<void>
|
|
96
|
+
finalizeSelected(): Promise<void>
|
|
97
|
+
sendTemplateToSelected(templateName?: string): Promise<void>
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function defaultDescribeFailure(error: unknown): string {
|
|
101
|
+
const status = (error as { status?: number }).status
|
|
102
|
+
if (status === 401 || status === 403) {
|
|
103
|
+
return 'Sessão expirada nesta aba — entre no painel de novo para ver as conversas.'
|
|
104
|
+
}
|
|
105
|
+
return error instanceof Error && error.message
|
|
106
|
+
? `Não foi possível carregar as conversas: ${error.message}`
|
|
107
|
+
: 'Não foi possível carregar as conversas.'
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function useConversationsInbox(params: UseConversationsInboxParams = {}): UseConversationsInboxResult {
|
|
111
|
+
const context = useConversations()
|
|
112
|
+
if (!context) {
|
|
113
|
+
throw new Error('useConversationsInbox requires an ancestor <ConversationsProvider>')
|
|
114
|
+
}
|
|
115
|
+
const { api } = context
|
|
116
|
+
const perPage = params.perPage ?? CONVERSATIONS_PER_PAGE
|
|
117
|
+
const markReadOnOpen = params.markReadOnOpen ?? true
|
|
118
|
+
const describeFailure = params.describeFailure ?? defaultDescribeFailure
|
|
119
|
+
|
|
120
|
+
const [waitingOnly, setWaitingOnly] = useState(false)
|
|
121
|
+
const [windowFilter, setWindowFilter] = useState<ConversationWindow>(CONVERSATION_WINDOW.ALL)
|
|
122
|
+
const [channelFilter, setChannelFilter] = useState<ChannelFilter>(CHANNEL_FILTER_ALL)
|
|
123
|
+
const [search, setSearch] = useState('')
|
|
124
|
+
const [page, setPage] = useState(1)
|
|
125
|
+
const [selectedId, setSelectedId] = useState<string | undefined>(undefined)
|
|
126
|
+
const [selectedIds, setSelectedIds] = useState<ReadonlySet<string>>(new Set())
|
|
127
|
+
const [busy, setBusy] = useState(false)
|
|
128
|
+
|
|
129
|
+
const { conversations, total, loading, error, refetch } = useConversationList({
|
|
130
|
+
...(waitingOnly ? { waitingHuman: true } : {}),
|
|
131
|
+
...(search ? { search } : {}),
|
|
132
|
+
...(params.filters ? { filters: params.filters } : {}),
|
|
133
|
+
...(params.serverPaginated ? { page, limit: perPage } : {}),
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
// `data-changed` é o único evento do canal global e vem sem payload: a resposta correta é refazer
|
|
137
|
+
// a query, não deduzir o que mudou.
|
|
138
|
+
useGlobalRealtime(
|
|
139
|
+
useCallback(() => {
|
|
140
|
+
void refetch()
|
|
141
|
+
}, [refetch]),
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
// Carimbo único por render: recalcular `Date.now()` por linha faria conversas na fronteira de
|
|
145
|
+
// 12h/21h/24h caírem em faixas diferentes na mesma tela.
|
|
146
|
+
const now = useMemo(() => Date.now(), [conversations])
|
|
147
|
+
|
|
148
|
+
const filtered = useMemo(
|
|
149
|
+
() =>
|
|
150
|
+
conversations
|
|
151
|
+
.filter(
|
|
152
|
+
(conversation) =>
|
|
153
|
+
channelFilter === CHANNEL_FILTER_ALL ||
|
|
154
|
+
(conversation.channel ?? DEFAULT_CONVERSATION_CHANNEL) === channelFilter,
|
|
155
|
+
)
|
|
156
|
+
.filter(
|
|
157
|
+
(conversation) =>
|
|
158
|
+
windowFilter === CONVERSATION_WINDOW.ALL ||
|
|
159
|
+
windowOf({ lastInboundAt: conversation.lastInboundAt, now, channel: conversation.channel }) ===
|
|
160
|
+
windowFilter,
|
|
161
|
+
),
|
|
162
|
+
[conversations, windowFilter, channelFilter, now],
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
// Com paginação no servidor o que veio JÁ é a página: refatiar deixaria a lista vazia da segunda
|
|
166
|
+
// página em diante, e o total do servidor é quem sabe quantas páginas existem.
|
|
167
|
+
const totalForPaging = params.serverPaginated ? total : filtered.length
|
|
168
|
+
const pageCount = Math.max(1, Math.ceil(totalForPaging / perPage))
|
|
169
|
+
const currentPage = Math.min(page, pageCount)
|
|
170
|
+
const pageConversations = params.serverPaginated
|
|
171
|
+
? filtered
|
|
172
|
+
: filtered.slice((currentPage - 1) * perPage, currentPage * perPage)
|
|
173
|
+
|
|
174
|
+
// Trocar de filtro com a página 7 ativa deixaria a lista vazia sem explicação.
|
|
175
|
+
useEffect(() => {
|
|
176
|
+
setPage(1)
|
|
177
|
+
}, [search, waitingOnly, windowFilter, channelFilter])
|
|
178
|
+
|
|
179
|
+
useEffect(() => {
|
|
180
|
+
if (selectedId && !conversations.some((conversation) => conversation.id === selectedId)) {
|
|
181
|
+
setSelectedId(undefined)
|
|
182
|
+
}
|
|
183
|
+
}, [conversations, selectedId])
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Guarda contra laço quente: marcar dispara `refetch`, que muda `conversations`, que reexecuta o
|
|
187
|
+
* efeito. `unread === 0` encerra o ciclo normal, mas com rota falhando ou dado velho o par se
|
|
188
|
+
* repetiria para sempre — uma requisição por render só aparece na aba deixada aberta.
|
|
189
|
+
*/
|
|
190
|
+
const lastMarkReadAttempt = useRef<string | undefined>(undefined)
|
|
191
|
+
|
|
192
|
+
useEffect(() => {
|
|
193
|
+
if (!markReadOnOpen || !selectedId) return
|
|
194
|
+
|
|
195
|
+
const opened = conversations.find((conversation) => conversation.id === selectedId)
|
|
196
|
+
if (!opened || opened.unread === 0) return
|
|
197
|
+
|
|
198
|
+
const attempt = `${selectedId}:${opened.unread}`
|
|
199
|
+
if (lastMarkReadAttempt.current === attempt) return
|
|
200
|
+
lastMarkReadAttempt.current = attempt
|
|
201
|
+
|
|
202
|
+
void api
|
|
203
|
+
.markRead(selectedId)
|
|
204
|
+
.then(() => refetch())
|
|
205
|
+
.catch(() => {
|
|
206
|
+
// Sem tratamento visível: falhar deixa o badge onde estava, que é a verdade. Um alerta a
|
|
207
|
+
// mais competiria com o atendimento.
|
|
208
|
+
})
|
|
209
|
+
}, [markReadOnOpen, selectedId, conversations, refetch, api])
|
|
210
|
+
|
|
211
|
+
const toggleSelected = useCallback((conversationId: string): void => {
|
|
212
|
+
setSelectedIds((current) => {
|
|
213
|
+
const next = new Set(current)
|
|
214
|
+
if (next.has(conversationId)) next.delete(conversationId)
|
|
215
|
+
else next.add(conversationId)
|
|
216
|
+
return next
|
|
217
|
+
})
|
|
218
|
+
}, [])
|
|
219
|
+
|
|
220
|
+
const pageIds = pageConversations.map((conversation) => conversation.id)
|
|
221
|
+
const allOnPageSelected = pageIds.length > 0 && pageIds.every((id) => selectedIds.has(id))
|
|
222
|
+
|
|
223
|
+
const toggleSelectAllOnPage = useCallback((): void => {
|
|
224
|
+
setSelectedIds((current) => {
|
|
225
|
+
const next = new Set(current)
|
|
226
|
+
if (allOnPageSelected) pageIds.forEach((id) => next.delete(id))
|
|
227
|
+
else pageIds.forEach((id) => next.add(id))
|
|
228
|
+
return next
|
|
229
|
+
})
|
|
230
|
+
}, [allOnPageSelected, pageIds])
|
|
231
|
+
|
|
232
|
+
const runOnSelection = useCallback(
|
|
233
|
+
async (action: (conversationId: string) => Promise<void>): Promise<void> => {
|
|
234
|
+
setBusy(true)
|
|
235
|
+
try {
|
|
236
|
+
// Em paralelo para não somar latência; é por não existir rota em lote que a ação age sobre
|
|
237
|
+
// a seleção, e não sobre a inbox inteira.
|
|
238
|
+
await Promise.all([...selectedIds].map(action))
|
|
239
|
+
setSelectedIds(new Set())
|
|
240
|
+
await refetch()
|
|
241
|
+
} finally {
|
|
242
|
+
setBusy(false)
|
|
243
|
+
}
|
|
244
|
+
},
|
|
245
|
+
[selectedIds, refetch],
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
const runConversationAction = useCallback(
|
|
249
|
+
async (action: ((conversationId: string) => Promise<void>) | undefined, conversationId: string) => {
|
|
250
|
+
if (!action) return
|
|
251
|
+
setBusy(true)
|
|
252
|
+
try {
|
|
253
|
+
await action(conversationId)
|
|
254
|
+
await refetch()
|
|
255
|
+
} finally {
|
|
256
|
+
setBusy(false)
|
|
257
|
+
}
|
|
258
|
+
},
|
|
259
|
+
[refetch],
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
return {
|
|
263
|
+
conversations,
|
|
264
|
+
pageConversations,
|
|
265
|
+
selectedConversation: conversations.find((conversation) => conversation.id === selectedId),
|
|
266
|
+
loading,
|
|
267
|
+
now,
|
|
268
|
+
// Com paginação no servidor `conversations` é só a página, então contar o array mostrava o
|
|
269
|
+
// tamanho da página no lugar do tamanho da base. Não lidas e aguardando seguem sendo da página:
|
|
270
|
+
// somar a base inteira exige agregado do servidor, que a listagem não devolve.
|
|
271
|
+
totalCount: params.serverPaginated ? total : conversations.length,
|
|
272
|
+
unreadCount: conversations.reduce((sum, conversation) => sum + conversation.unread, 0),
|
|
273
|
+
waitingCount: conversations.filter((conversation) => conversation.waitingHuman).length,
|
|
274
|
+
filteredCount: totalForPaging,
|
|
275
|
+
expiredSelectedCount: conversations.filter(
|
|
276
|
+
(conversation) =>
|
|
277
|
+
selectedIds.has(conversation.id) &&
|
|
278
|
+
isWindowBlocking(windowOf({ lastInboundAt: conversation.lastInboundAt, now, channel: conversation.channel })),
|
|
279
|
+
).length,
|
|
280
|
+
page: currentPage,
|
|
281
|
+
pageCount,
|
|
282
|
+
firstOnPage: (currentPage - 1) * perPage + 1,
|
|
283
|
+
lastOnPage: Math.min(currentPage * perPage, totalForPaging),
|
|
284
|
+
selectedId,
|
|
285
|
+
selectedIds,
|
|
286
|
+
allOnPageSelected,
|
|
287
|
+
waitingOnly,
|
|
288
|
+
windowFilter,
|
|
289
|
+
channelFilter,
|
|
290
|
+
channelFilters: channelFiltersFor(conversations),
|
|
291
|
+
search,
|
|
292
|
+
busy,
|
|
293
|
+
loadFailure: error ? describeFailure(error) : undefined,
|
|
294
|
+
canTakeover: Boolean(api.takeover),
|
|
295
|
+
canFinalize: Boolean(api.finalize),
|
|
296
|
+
canListTemplates: Boolean(api.listTemplates),
|
|
297
|
+
canMarkAllRead: Boolean(api.markAllRead),
|
|
298
|
+
refetch,
|
|
299
|
+
selectConversation: setSelectedId,
|
|
300
|
+
clearSelection: () => setSelectedId(undefined),
|
|
301
|
+
toggleSelected,
|
|
302
|
+
toggleSelectAllOnPage,
|
|
303
|
+
clearBulkSelection: () => setSelectedIds(new Set()),
|
|
304
|
+
setWaitingOnly,
|
|
305
|
+
setWindowFilter,
|
|
306
|
+
setChannelFilter,
|
|
307
|
+
setSearch,
|
|
308
|
+
goToPage: setPage,
|
|
309
|
+
markSelectedAsRead: () => runOnSelection((conversationId) => api.markRead(conversationId)),
|
|
310
|
+
markAllAsRead: async () => {
|
|
311
|
+
if (!api.markAllRead) return
|
|
312
|
+
setBusy(true)
|
|
313
|
+
try {
|
|
314
|
+
await api.markAllRead()
|
|
315
|
+
await refetch()
|
|
316
|
+
} finally {
|
|
317
|
+
setBusy(false)
|
|
318
|
+
}
|
|
319
|
+
},
|
|
320
|
+
takeover: (conversationId) => runConversationAction(api.takeover, conversationId),
|
|
321
|
+
releaseToBot: (conversationId) => runConversationAction(api.release, conversationId),
|
|
322
|
+
finalize: (conversationId) => runConversationAction(api.finalize, conversationId),
|
|
323
|
+
finalizeSelected: () =>
|
|
324
|
+
runOnSelection(async (conversationId) => {
|
|
325
|
+
await api.finalize?.(conversationId)
|
|
326
|
+
}),
|
|
327
|
+
sendTemplateToSelected: (templateName) =>
|
|
328
|
+
runOnSelection(async (conversationId) => {
|
|
329
|
+
await api.sendTemplate(conversationId, templateName ? { templateName } : {})
|
|
330
|
+
}),
|
|
331
|
+
}
|
|
332
|
+
}
|