@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.
Files changed (129) hide show
  1. package/dist/ConversationSimulatorPanel--5fIzXWY.d.ts +804 -0
  2. package/dist/{chunk-G5BM3VBP.js → chunk-BJNRLLDO.js} +1248 -282
  3. package/dist/chunk-DKPXKQGC.js +110 -0
  4. package/dist/{chunk-2AYDBWNE.js → chunk-WCBDXZ3X.js} +13 -3
  5. package/dist/flows/index.d.ts +422 -5
  6. package/dist/flows/index.js +2502 -676
  7. package/dist/index.d.ts +919 -17
  8. package/dist/index.js +3676 -675
  9. package/dist/preview/index.d.ts +62 -105
  10. package/dist/preview/index.js +162 -284
  11. package/dist/styles.css +893 -0
  12. package/package.json +9 -8
  13. package/src/AudioPlayer.tsx +8 -0
  14. package/src/AudioRecorderButton.test.tsx +30 -0
  15. package/src/AudioRecorderButton.tsx +248 -0
  16. package/src/AudioTranscription.test.tsx +115 -0
  17. package/src/AudioTranscription.tsx +252 -0
  18. package/src/Avatar.tsx +1 -1
  19. package/src/ConversationContextPanel.tsx +218 -44
  20. package/src/ConversationDocumentsPanel.tsx +11 -6
  21. package/src/ConversationHeader.test.tsx +66 -0
  22. package/src/ConversationHeader.tsx +147 -47
  23. package/src/ConversationListItem.tsx +8 -6
  24. package/src/ConversationLocalesProvider.tsx +28 -0
  25. package/src/ConversationRow.tsx +53 -7
  26. package/src/DarkModeToggle.test.tsx +76 -0
  27. package/src/DarkModeToggle.tsx +92 -0
  28. package/src/DocumentsLibrary.tsx +67 -7
  29. package/src/EmojiPicker.tsx +2 -1
  30. package/src/InteractiveMessage.tsx +3 -0
  31. package/src/Lightbox.tsx +1 -1
  32. package/src/MediaRenderer.tsx +88 -15
  33. package/src/MessageBubble.test.tsx +41 -0
  34. package/src/MessageBubble.tsx +47 -5
  35. package/src/MessageComposer.test.tsx +35 -0
  36. package/src/MessageComposer.tsx +122 -17
  37. package/src/MessageText.tsx +2 -1
  38. package/src/MessageTimestamp.tsx +2 -1
  39. package/src/RichMessageComposer.test.tsx +113 -0
  40. package/src/RichMessageComposer.tsx +551 -0
  41. package/src/SimpleEmojiPicker.tsx +5 -3
  42. package/src/StatusTicks.tsx +1 -1
  43. package/src/Toast.tsx +4 -0
  44. package/src/Tooltip.test.ts +42 -0
  45. package/src/Tooltip.tsx +167 -0
  46. package/src/Wallpaper.tsx +27 -13
  47. package/src/WhatsAppMessageEditor.tsx +10 -7
  48. package/src/WindowExpiredNotice.tsx +12 -4
  49. package/src/{preview/audioRecorderFormat.test.ts → audioRecorderFormat.test.ts} +1 -1
  50. package/src/buildOutput.test.ts +79 -0
  51. package/src/composer.constant.ts +33 -0
  52. package/src/conversationTranscript.test.ts +57 -0
  53. package/src/conversationTranscript.ts +29 -4
  54. package/src/conversationWindow.ts +7 -5
  55. package/src/documentTypeLabel.test.ts +57 -0
  56. package/src/documents/DocumentsWorkspace.tsx +550 -0
  57. package/src/documents/index.ts +8 -0
  58. package/src/documents/labels.ts +92 -0
  59. package/src/flows/FlowConnectionEdge.tsx +104 -0
  60. package/src/flows/FlowGroupHeader.tsx +12 -2
  61. package/src/flows/FlowLegend.tsx +125 -0
  62. package/src/flows/FlowMapCanvas.tsx +15 -12
  63. package/src/flows/FlowMapNode.tsx +4 -1
  64. package/src/flows/FlowNodeCard.tsx +219 -34
  65. package/src/flows/FlowNodePanel.tsx +153 -39
  66. package/src/flows/FlowPalette.tsx +156 -70
  67. package/src/flows/FlowPortalNode.tsx +1 -1
  68. package/src/flows/FlowWhatsAppPreview.tsx +14 -3
  69. package/src/flows/FlowsWorkspace.tsx +1255 -0
  70. package/src/flows/flowCanvasModel.test.ts +456 -0
  71. package/src/flows/flowCanvasModel.ts +378 -0
  72. package/src/flows/flowEditorOps.test.ts +276 -0
  73. package/src/flows/flowEditorOps.ts +202 -0
  74. package/src/flows/flowGraph.ts +78 -53
  75. package/src/flows/flowMenuPlacement.test.ts +130 -0
  76. package/src/flows/flowMenuPlacement.ts +86 -0
  77. package/src/flows/index.ts +51 -2
  78. package/src/flows/labels.ts +180 -0
  79. package/src/flows/workspaceContract.test.ts +126 -0
  80. package/src/hooks/useContainerWidth.ts +35 -0
  81. package/src/hooks/useConversationRealtime.ts +10 -8
  82. package/src/hooks/useScrollToLatestMessage.ts +127 -0
  83. package/src/hooks/useUrlFilterState.ts +107 -0
  84. package/src/icon.constant.ts +12 -0
  85. package/src/index.ts +100 -0
  86. package/src/lib/composer-formatting.test.ts +78 -0
  87. package/src/lib/composer-formatting.ts +145 -0
  88. package/src/lib/whatsapp-formatting.test.tsx +37 -0
  89. package/src/lib/whatsapp-formatting.tsx +28 -3
  90. package/src/listing/index.tsx +202 -0
  91. package/src/pagination.constant.ts +10 -0
  92. package/src/preview/ConversationPreview.tsx +84 -45
  93. package/src/preview/ConversationSimulatorClient.ts +143 -0
  94. package/src/preview/ConversationSimulatorPanel.test.tsx +55 -0
  95. package/src/preview/ConversationSimulatorPanel.tsx +131 -0
  96. package/src/preview/createPreviewBridgeClient.test.ts +92 -0
  97. package/src/preview/createPreviewBridgeClient.ts +124 -0
  98. package/src/preview/createPreviewMediaUploader.ts +82 -0
  99. package/src/preview/createPreviewWebhookClient.test.ts +96 -0
  100. package/src/preview/createPreviewWebhookClient.ts +99 -3
  101. package/src/preview/index.ts +36 -2
  102. package/src/preview/previewMediaUploader.test.ts +61 -0
  103. package/src/providers/ConversationsProvider.tsx +8 -6
  104. package/src/providers/types.ts +59 -2
  105. package/src/quickReply.test.ts +58 -0
  106. package/src/replyLatency.test.ts +71 -0
  107. package/src/replyLatency.ts +57 -0
  108. package/src/settings/MessagesWorkspace.tsx +571 -0
  109. package/src/settings/TopicsForm.tsx +2 -0
  110. package/src/settings/TranscriptionSettingsForm.test.tsx +81 -0
  111. package/src/settings/TranscriptionSettingsForm.tsx +190 -0
  112. package/src/settings/WelcomeFarewellForm.tsx +1 -0
  113. package/src/settings/WhatsAppCreateTemplateForm.tsx +1 -0
  114. package/src/settings/WhatsAppTemplateSettingsForm.tsx +5 -2
  115. package/src/settings/WhatsAppTemplatesSettings.test.tsx +61 -0
  116. package/src/settings/WhatsAppTemplatesSettings.tsx +22 -2
  117. package/src/styles.css +858 -0
  118. package/src/theme.ts +13 -0
  119. package/src/types.ts +26 -0
  120. package/src/workspace/BulkTemplateModal.tsx +132 -0
  121. package/src/workspace/ConversationPane.tsx +432 -0
  122. package/src/workspace/ConversationsInboxList.tsx +194 -0
  123. package/src/workspace/ConversationsWorkspace.tsx +423 -0
  124. package/src/workspace/index.ts +17 -0
  125. package/src/workspace/labels.test.ts +17 -0
  126. package/src/workspace/labels.ts +85 -0
  127. package/src/workspace/useConversationsInbox.ts +332 -0
  128. package/dist/types-B5C1DLu1.d.ts +0 -365
  129. 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
+ }