@adatechnology/conversations-ui 0.1.0-rc.1 → 0.1.0-rc.11
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/{chunk-ZDURDZTM.js → chunk-2AYDBWNE.js} +14 -0
- package/dist/chunk-3RKFU46R.js +1778 -0
- package/dist/flows/index.js +3 -3
- package/dist/index.d.ts +644 -136
- package/dist/index.js +1068 -1024
- package/dist/preview/index.d.ts +341 -0
- package/dist/preview/index.js +1296 -0
- package/dist/styles.css +198 -0
- package/dist/types-B5C1DLu1.d.ts +365 -0
- package/package.json +10 -3
- package/src/Avatar.tsx +30 -4
- package/src/ChannelIcon.tsx +87 -0
- package/src/ConversationContextPanel.tsx +106 -0
- package/src/ConversationDocumentsPanel.tsx +425 -0
- package/src/ConversationHeader.tsx +257 -0
- package/src/ConversationListItem.tsx +54 -7
- package/src/ConversationLocalesProvider.tsx +16 -0
- package/src/ConversationRow.tsx +137 -0
- package/src/DateDivider.tsx +16 -3
- package/src/DocumentsLibrary.tsx +322 -0
- package/src/EmojiPicker.tsx +69 -55
- package/src/FileIcon.test.ts +83 -0
- package/src/FileIcon.tsx +88 -11
- package/src/InteractiveMessage.test.tsx +41 -0
- package/src/InteractiveMessage.tsx +143 -0
- package/src/Lightbox.tsx +18 -3
- package/src/MediaRenderer.tsx +14 -11
- package/src/MessageBubble.tsx +54 -5
- package/src/MessageComposer.test.tsx +35 -0
- package/src/MessageComposer.tsx +146 -19
- package/src/Wallpaper.test.tsx +21 -0
- package/src/Wallpaper.tsx +54 -6
- package/src/WhatsAppMessageEditor.tsx +28 -4
- package/src/WindowExpiredNotice.tsx +57 -0
- package/src/conversationChannel.test.ts +53 -0
- package/src/conversationChannel.ts +146 -0
- package/src/conversationTranscript.test.ts +65 -0
- package/src/conversationTranscript.ts +64 -0
- package/src/conversationWindow.test.ts +90 -0
- package/src/conversationWindow.ts +78 -0
- package/src/emojiCatalog.test.ts +35 -0
- package/src/emojiCatalog.ts +189 -0
- package/src/flows/FlowMapCanvas.tsx +2 -2
- package/src/hooks/useConversationActions.ts +56 -0
- package/src/hooks/useConversationDocuments.ts +15 -9
- package/src/hooks/useConversationList.ts +15 -9
- package/src/hooks/useConversationMessages.ts +2 -2
- package/src/index.ts +114 -16
- package/src/lib/cn.test.ts +29 -0
- package/src/lib/cn.ts +15 -0
- package/src/lib/createMediaUrlResolver.ts +33 -0
- package/src/lib/paginated.test.ts +33 -0
- package/src/lib/paginated.ts +26 -0
- package/src/lib/phone.ts +34 -0
- package/src/preview/AudioRecorderButton.tsx +155 -0
- package/src/preview/ConversationPreview.tsx +322 -0
- package/src/preview/MediaTypesPreview.tsx +87 -0
- package/src/preview/audioRecorderFormat.test.ts +67 -0
- package/src/preview/conversationPreviewFailures.test.ts +64 -0
- package/src/preview/createMockConversationsApi.ts +271 -0
- package/src/preview/createMockSSEProvider.ts +40 -0
- package/src/preview/createPreviewWebhookClient.test.ts +105 -0
- package/src/preview/createPreviewWebhookClient.ts +126 -0
- package/src/preview/index.ts +54 -0
- package/src/preview/mediaTypeOf.test.ts +15 -0
- package/src/preview/mockDocumentsSearch.test.ts +57 -0
- package/src/preview/mockEventSource.ts +53 -0
- package/src/preview/preview.test.ts +177 -0
- package/src/preview/previewFileSamples.test.ts +151 -0
- package/src/preview/previewFileSamples.ts +74 -0
- package/src/preview/previewFixtures.ts +440 -0
- package/src/preview/previewMediaSource.test.ts +62 -0
- package/src/preview/previewMediaSource.ts +91 -0
- package/src/preview/previewStore.ts +193 -0
- package/src/preview/startPreviewScript.ts +60 -0
- package/src/providers/types.ts +163 -11
- package/src/quickReply.test.ts +58 -0
- package/src/settings/WhatsAppCreateTemplateForm.tsx +3 -1
- package/src/settings/WhatsAppTemplatesSettings.tsx +106 -0
- package/src/styles.css +136 -0
- package/src/types.ts +46 -1
- package/src/useDarkMode.ts +26 -0
- package/src/useIsNarrow.ts +29 -0
- package/src/useWaitingNotifications.ts +74 -29
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test'
|
|
2
|
+
import { buildTranscriptFilename, buildTranscriptText } from './conversationTranscript'
|
|
3
|
+
import type { MessagePayload } from './types'
|
|
4
|
+
|
|
5
|
+
const NUMBER = '5511988887777'
|
|
6
|
+
|
|
7
|
+
function message(partial: Partial<MessagePayload>): MessagePayload {
|
|
8
|
+
return {
|
|
9
|
+
id: 'm1',
|
|
10
|
+
type: 'text',
|
|
11
|
+
direction: 'inbound',
|
|
12
|
+
sender: 'customer',
|
|
13
|
+
timestamp: '2026-07-27T12:00:00.000Z',
|
|
14
|
+
...partial,
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
describe('buildTranscriptText', () => {
|
|
19
|
+
it('escreve cabeçalho e uma linha por mensagem', () => {
|
|
20
|
+
const text = buildTranscriptText({
|
|
21
|
+
messages: [
|
|
22
|
+
message({ id: 'm1', content: 'oi' }),
|
|
23
|
+
message({ id: 'm2', content: 'Olá!', direction: 'outbound', sender: 'bot' }),
|
|
24
|
+
],
|
|
25
|
+
whatsappNumber: NUMBER,
|
|
26
|
+
clientName: 'Marina',
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
expect(text).toContain('Conversa: Marina')
|
|
30
|
+
expect(text).toContain(`Número: ${NUMBER}`)
|
|
31
|
+
expect(text).toContain('Mensagens: 2')
|
|
32
|
+
expect(text).toContain('Cliente: oi')
|
|
33
|
+
expect(text).toContain('Bot: Olá!')
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
// Sem isto a linha sairia vazia e o histórico esconderia que houve um anexo ali.
|
|
37
|
+
it('marca o tipo quando a mensagem não tem texto', () => {
|
|
38
|
+
const text = buildTranscriptText({ messages: [message({ type: 'audio' })], whatsappNumber: NUMBER })
|
|
39
|
+
|
|
40
|
+
expect(text).toContain('<audio>')
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it('usa o nome do atendente quando existe, em vez do papel genérico', () => {
|
|
44
|
+
const text = buildTranscriptText({
|
|
45
|
+
messages: [message({ direction: 'outbound', sender: 'agent', agentName: 'Ana', content: 'já separei' })],
|
|
46
|
+
whatsappNumber: NUMBER,
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
expect(text).toContain('Ana: já separei')
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('cai no número quando não há nome do cliente', () => {
|
|
53
|
+
const text = buildTranscriptText({ messages: [], whatsappNumber: NUMBER })
|
|
54
|
+
|
|
55
|
+
expect(text).toContain(`Conversa: ${NUMBER}`)
|
|
56
|
+
})
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
describe('buildTranscriptFilename', () => {
|
|
60
|
+
it('inclui número e data', () => {
|
|
61
|
+
expect(buildTranscriptFilename(NUMBER, new Date('2026-07-27T23:00:00.000Z'))).toBe(
|
|
62
|
+
`conversa-${NUMBER}-2026-07-27.txt`,
|
|
63
|
+
)
|
|
64
|
+
})
|
|
65
|
+
})
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Serialização do transcript para download. Fica no pacote porque o formato de um histórico de
|
|
3
|
+
* WhatsApp legível não é regra de negócio de ninguém — e porque o host que já tem as mensagens em
|
|
4
|
+
* tela não deveria precisar de rota nova só para salvar um arquivo.
|
|
5
|
+
*
|
|
6
|
+
* Funções puras, separadas do disparo do download: é o que permite testá-las sem DOM.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { MessagePayload } from './types'
|
|
10
|
+
|
|
11
|
+
const SENDER_LABEL: Record<MessagePayload['sender'], string> = {
|
|
12
|
+
customer: 'Cliente',
|
|
13
|
+
bot: 'Bot',
|
|
14
|
+
agent: 'Atendente',
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export type BuildTranscriptTextParams = {
|
|
18
|
+
readonly messages: readonly MessagePayload[]
|
|
19
|
+
readonly whatsappNumber: string
|
|
20
|
+
readonly clientName?: string | undefined
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Formato próximo ao export nativo do WhatsApp (`[data hora] Autor: texto`), que é o que pessoas e
|
|
25
|
+
* ferramentas de suporte já sabem ler.
|
|
26
|
+
*/
|
|
27
|
+
export function buildTranscriptText(params: BuildTranscriptTextParams): string {
|
|
28
|
+
const header = [
|
|
29
|
+
`Conversa: ${params.clientName ?? params.whatsappNumber}`,
|
|
30
|
+
`Número: ${params.whatsappNumber}`,
|
|
31
|
+
`Mensagens: ${params.messages.length}`,
|
|
32
|
+
'',
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
const lines = params.messages.map((message) => {
|
|
36
|
+
const stamp = new Date(message.timestamp).toLocaleString('pt-BR')
|
|
37
|
+
const author = message.agentName ?? SENDER_LABEL[message.sender]
|
|
38
|
+
// Mídia sem legenda não tem texto nenhum; marcar o tipo evita uma linha vazia sem explicação.
|
|
39
|
+
const body = message.content ?? message.caption ?? `<${message.type}>`
|
|
40
|
+
return `[${stamp}] ${author}: ${body}`
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
return [...header, ...lines].join('\n')
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function buildTranscriptFilename(whatsappNumber: string, generatedAt: Date): string {
|
|
47
|
+
const stamp = generatedAt.toISOString().slice(0, 10)
|
|
48
|
+
return `conversa-${whatsappNumber}-${stamp}.txt`
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Dispara o download no navegador. `revokeObjectURL` no fim não é higiene opcional: sem ele cada
|
|
53
|
+
* export retém o blob inteiro em memória até a aba fechar.
|
|
54
|
+
*/
|
|
55
|
+
export function downloadTextFile(filename: string, content: string): void {
|
|
56
|
+
const blob = new Blob([content], { type: 'text/plain;charset=utf-8' })
|
|
57
|
+
const url = URL.createObjectURL(blob)
|
|
58
|
+
const anchor = document.createElement('a')
|
|
59
|
+
|
|
60
|
+
anchor.href = url
|
|
61
|
+
anchor.download = filename
|
|
62
|
+
anchor.click()
|
|
63
|
+
URL.revokeObjectURL(url)
|
|
64
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A classificação de janela decide se o atendente pode mandar texto livre. Errar a fronteira não
|
|
3
|
+
* quebra a tela — faz o WhatsApp recusar o envio depois, longe da causa.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { describe, expect, it } from 'bun:test'
|
|
7
|
+
import { CONVERSATION_WINDOW, formatStalledFor, windowOf } from './conversationWindow'
|
|
8
|
+
import { CONVERSATION_CHANNEL, contactFlag, formatContactHandle } from './conversationChannel'
|
|
9
|
+
|
|
10
|
+
const NOW = new Date('2026-07-27T12:00:00.000Z').getTime()
|
|
11
|
+
const HOUR = 60 * 60 * 1000
|
|
12
|
+
|
|
13
|
+
function hoursAgo(hours: number): string {
|
|
14
|
+
return new Date(NOW - hours * HOUR).toISOString()
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
describe('windowOf', () => {
|
|
18
|
+
it('classifica por faixa de horas desde o último contato do cliente', () => {
|
|
19
|
+
expect(windowOf({ lastInboundAt: hoursAgo(0), now: NOW })).toBe(CONVERSATION_WINDOW.FRESH)
|
|
20
|
+
expect(windowOf({ lastInboundAt: hoursAgo(11.9), now: NOW })).toBe(CONVERSATION_WINDOW.FRESH)
|
|
21
|
+
expect(windowOf({ lastInboundAt: hoursAgo(15), now: NOW })).toBe(CONVERSATION_WINDOW.WARNING)
|
|
22
|
+
expect(windowOf({ lastInboundAt: hoursAgo(22), now: NOW })).toBe(CONVERSATION_WINDOW.CRITICAL)
|
|
23
|
+
expect(windowOf({ lastInboundAt: hoursAgo(30), now: NOW })).toBe(CONVERSATION_WINDOW.EXPIRED)
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
// As fronteiras são exatamente onde o erro custa caro: 24h em ponto já é recusa da Meta.
|
|
27
|
+
it('trata as fronteiras como início da faixa seguinte', () => {
|
|
28
|
+
expect(windowOf({ lastInboundAt: hoursAgo(12), now: NOW })).toBe(CONVERSATION_WINDOW.WARNING)
|
|
29
|
+
expect(windowOf({ lastInboundAt: hoursAgo(21), now: NOW })).toBe(CONVERSATION_WINDOW.CRITICAL)
|
|
30
|
+
expect(windowOf({ lastInboundAt: hoursAgo(24), now: NOW })).toBe(CONVERSATION_WINDOW.EXPIRED)
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
// Sem inbound não há janela aberta; classificar como expirada evita prometer texto livre.
|
|
34
|
+
it('considera expirada quando o cliente nunca escreveu', () => {
|
|
35
|
+
expect(windowOf({ lastInboundAt: null, now: NOW })).toBe(CONVERSATION_WINDOW.EXPIRED)
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
// O defeito que isto tranca: aplicar a regra do WhatsApp ao chat de site bloquearia o composer
|
|
39
|
+
// num canal onde nada expira.
|
|
40
|
+
it('nunca expira em canal sem janela de sessão', () => {
|
|
41
|
+
expect(windowOf({ lastInboundAt: hoursAgo(720), now: NOW, channel: CONVERSATION_CHANNEL.WEBCHAT })).toBe(
|
|
42
|
+
CONVERSATION_WINDOW.FRESH,
|
|
43
|
+
)
|
|
44
|
+
expect(windowOf({ lastInboundAt: null, now: NOW, channel: CONVERSATION_CHANNEL.WEBCHAT })).toBe(
|
|
45
|
+
CONVERSATION_WINDOW.FRESH,
|
|
46
|
+
)
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('mantém a regra do WhatsApp quando o canal não é informado', () => {
|
|
50
|
+
expect(windowOf({ lastInboundAt: hoursAgo(30), now: NOW })).toBe(
|
|
51
|
+
windowOf({ lastInboundAt: hoursAgo(30), now: NOW, channel: CONVERSATION_CHANNEL.WHATSAPP }),
|
|
52
|
+
)
|
|
53
|
+
})
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
describe('formatContactHandle', () => {
|
|
57
|
+
it('formata telefone no WhatsApp e arroba nas redes', () => {
|
|
58
|
+
expect(formatContactHandle({ handle: '5511988887777', channel: CONVERSATION_CHANNEL.WHATSAPP })).toBe(
|
|
59
|
+
'+55 (11) 98888-7777',
|
|
60
|
+
)
|
|
61
|
+
expect(formatContactHandle({ handle: 'marina.alves', channel: CONVERSATION_CHANNEL.INSTAGRAM })).toBe(
|
|
62
|
+
'@marina.alves',
|
|
63
|
+
)
|
|
64
|
+
expect(formatContactHandle({ handle: '@ja.tem', channel: CONVERSATION_CHANNEL.INSTAGRAM })).toBe('@ja.tem')
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it('encurta a sessão anônima do chat de site', () => {
|
|
68
|
+
expect(formatContactHandle({ handle: 'sess_9f2a7c41b8', channel: CONVERSATION_CHANNEL.WEBCHAT })).toBe(
|
|
69
|
+
'Visitante 7c41b8',
|
|
70
|
+
)
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
// Bandeira em @perfil não significaria nada: o identificador não carrega país.
|
|
74
|
+
it('só devolve bandeira quando o identificador é telefone', () => {
|
|
75
|
+
expect(contactFlag({ handle: '5511988887777' })).toBe('🇧🇷')
|
|
76
|
+
expect(contactFlag({ handle: 'marina.alves', channel: CONVERSATION_CHANNEL.INSTAGRAM })).toBe('')
|
|
77
|
+
})
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
describe('formatStalledFor', () => {
|
|
81
|
+
it('formata dias, horas e minutos', () => {
|
|
82
|
+
expect(formatStalledFor(hoursAgo(5 * 24 + 6.2), NOW)).toBe('5d 6h:12m')
|
|
83
|
+
expect(formatStalledFor(hoursAgo(3.5), NOW)).toBe('3h:30m')
|
|
84
|
+
expect(formatStalledFor(hoursAgo(0.25), NOW)).toBe('15m')
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
it('não devolve tempo negativo para carimbo no futuro', () => {
|
|
88
|
+
expect(formatStalledFor(new Date(NOW + HOUR).toISOString(), NOW)).toBe('0m')
|
|
89
|
+
})
|
|
90
|
+
})
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Janela de sessão: o intervalo em que o canal aceita mensagem livre do atendente. No WhatsApp são
|
|
3
|
+
* 24h desde o último contato do cliente; fora dela só template. Cada canal tem a sua regra — e há
|
|
4
|
+
* canal sem janela nenhuma — então a política vem de `capabilitiesOf`, não de constante fixa.
|
|
5
|
+
*
|
|
6
|
+
* Mora no SDK porque é regra de plataforma, não de produto: todo projeto que usa este pacote
|
|
7
|
+
* precisa dela para saber o que o atendente ainda consegue fazer.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { capabilitiesOf, type ConversationChannel } from './conversationChannel'
|
|
11
|
+
|
|
12
|
+
const MINUTE_MS = 60_000
|
|
13
|
+
const HOUR_MS = 60 * MINUTE_MS
|
|
14
|
+
const DAY_MS = 24 * HOUR_MS
|
|
15
|
+
|
|
16
|
+
export const CONVERSATION_WINDOW = {
|
|
17
|
+
ALL: 'all',
|
|
18
|
+
FRESH: 'fresh',
|
|
19
|
+
WARNING: 'warning',
|
|
20
|
+
CRITICAL: 'critical',
|
|
21
|
+
EXPIRED: 'expired',
|
|
22
|
+
} as const
|
|
23
|
+
export type ConversationWindow = (typeof CONVERSATION_WINDOW)[keyof typeof CONVERSATION_WINDOW]
|
|
24
|
+
|
|
25
|
+
export const WINDOW_FILTERS = [
|
|
26
|
+
{ value: CONVERSATION_WINDOW.ALL, label: 'Todas', dotClass: '' },
|
|
27
|
+
{ value: CONVERSATION_WINDOW.FRESH, label: '<12h', dotClass: 'bg-green-500' },
|
|
28
|
+
{ value: CONVERSATION_WINDOW.WARNING, label: '12-21h', dotClass: 'bg-yellow-500' },
|
|
29
|
+
{ value: CONVERSATION_WINDOW.CRITICAL, label: '21-24h', dotClass: 'bg-red-500' },
|
|
30
|
+
{ value: CONVERSATION_WINDOW.EXPIRED, label: '>24h', dotClass: 'bg-gray-400' },
|
|
31
|
+
] as const
|
|
32
|
+
|
|
33
|
+
// Proporções da janela: 12h e 21h numa janela de 24h, mas expressas como fração para acompanharem
|
|
34
|
+
// canais com janela de outro tamanho.
|
|
35
|
+
const WARNING_RATIO = 0.5
|
|
36
|
+
const CRITICAL_RATIO = 0.875
|
|
37
|
+
|
|
38
|
+
export type WindowOfParams = {
|
|
39
|
+
readonly lastInboundAt: string | null
|
|
40
|
+
readonly now: number
|
|
41
|
+
/** Ausente = `whatsapp`. */
|
|
42
|
+
readonly channel?: ConversationChannel | undefined
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Sem `lastInboundAt` o cliente nunca escreveu, então não há janela aberta — classificar como
|
|
47
|
+
* expirada é o comportamento seguro: evita o atendente tentar texto livre e receber recusa.
|
|
48
|
+
*
|
|
49
|
+
* Canal sem janela de sessão (chat de site) é sempre `fresh`: ali nada expira, e marcar expirado
|
|
50
|
+
* bloquearia o composer inventando um limite que a plataforma não impõe.
|
|
51
|
+
*/
|
|
52
|
+
export function windowOf(params: WindowOfParams): ConversationWindow {
|
|
53
|
+
const capabilities = capabilitiesOf(params.channel)
|
|
54
|
+
if (!capabilities.hasSessionWindow) return CONVERSATION_WINDOW.FRESH
|
|
55
|
+
|
|
56
|
+
if (!params.lastInboundAt) return CONVERSATION_WINDOW.EXPIRED
|
|
57
|
+
|
|
58
|
+
const windowMs = capabilities.windowHours * HOUR_MS
|
|
59
|
+
const elapsed = params.now - new Date(params.lastInboundAt).getTime()
|
|
60
|
+
|
|
61
|
+
if (elapsed >= windowMs) return CONVERSATION_WINDOW.EXPIRED
|
|
62
|
+
// Faixas proporcionais à janela do canal, e não fixas em 12h/21h: um canal com janela diferente
|
|
63
|
+
// de 24h teria os avisos nos lugares errados.
|
|
64
|
+
if (elapsed >= windowMs * CRITICAL_RATIO) return CONVERSATION_WINDOW.CRITICAL
|
|
65
|
+
if (elapsed >= windowMs * WARNING_RATIO) return CONVERSATION_WINDOW.WARNING
|
|
66
|
+
return CONVERSATION_WINDOW.FRESH
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function formatStalledFor(lastAt: string, now: number): string {
|
|
70
|
+
const elapsed = Math.max(0, now - new Date(lastAt).getTime())
|
|
71
|
+
const days = Math.floor(elapsed / DAY_MS)
|
|
72
|
+
const hours = Math.floor((elapsed % DAY_MS) / HOUR_MS)
|
|
73
|
+
const minutes = Math.floor((elapsed % HOUR_MS) / MINUTE_MS)
|
|
74
|
+
|
|
75
|
+
if (days > 0) return `${days}d ${hours}h:${String(minutes).padStart(2, '0')}m`
|
|
76
|
+
if (hours > 0) return `${hours}h:${String(minutes).padStart(2, '0')}m`
|
|
77
|
+
return `${minutes}m`
|
|
78
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test'
|
|
2
|
+
import { EMOJI_CATEGORIES, searchEmojis } from './emojiCatalog'
|
|
3
|
+
|
|
4
|
+
describe('searchEmojis', () => {
|
|
5
|
+
it('devolve o catálogo inteiro quando não há termo', () => {
|
|
6
|
+
const total = EMOJI_CATEGORIES.reduce((sum, category) => sum + category.entries.length, 0)
|
|
7
|
+
expect(searchEmojis('').length).toBe(total)
|
|
8
|
+
expect(searchEmojis(' ').length).toBe(total)
|
|
9
|
+
})
|
|
10
|
+
|
|
11
|
+
it('acha pela palavra sem acento e com acento', () => {
|
|
12
|
+
const semAcento = searchEmojis('coracao').map((entry) => entry.emoji)
|
|
13
|
+
const comAcento = searchEmojis('coração').map((entry) => entry.emoji)
|
|
14
|
+
|
|
15
|
+
expect(semAcento).toContain('❤️')
|
|
16
|
+
expect(comAcento).toContain('❤️')
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
it('ignora caixa e casa por prefixo, não pelo meio da palavra', () => {
|
|
20
|
+
expect(searchEmojis('CASA').map((entry) => entry.emoji)).toContain('🏠')
|
|
21
|
+
// "sa" está dentro de "casa", mas não é prefixo de nenhuma palavra-chave dela.
|
|
22
|
+
expect(searchEmojis('sa').map((entry) => entry.emoji)).not.toContain('🏠')
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it('atravessa categorias, não só a aberta', () => {
|
|
26
|
+
const resultados = searchEmojis('pagamento').map((entry) => entry.emoji)
|
|
27
|
+
|
|
28
|
+
expect(resultados).toContain('💳')
|
|
29
|
+
expect(resultados.length).toBeGreaterThan(1)
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it('devolve vazio quando nada casa, em vez de cair no catálogo inteiro', () => {
|
|
33
|
+
expect(searchEmojis('xyzabc')).toEqual([])
|
|
34
|
+
})
|
|
35
|
+
})
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Catálogo de emojis com palavras-chave em português, usado pela busca do seletor.
|
|
3
|
+
*
|
|
4
|
+
* Fica em módulo próprio (e não dentro do `EmojiPicker`) porque a busca precisa varrer TODAS as
|
|
5
|
+
* categorias, não só a aberta — o índice é montado uma vez, no carregamento, e não a cada tecla.
|
|
6
|
+
*
|
|
7
|
+
* As palavras-chave são de busca, não legendas: incluem sinônimos e formas sem acento, porque quem
|
|
8
|
+
* digita rápido escreve "coracao" e "polegar" com a mesma frequência que a forma correta.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export type EmojiEntry = {
|
|
12
|
+
readonly emoji: string
|
|
13
|
+
readonly keywords: readonly string[]
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export type EmojiCategory = {
|
|
17
|
+
readonly name: string
|
|
18
|
+
readonly entries: readonly EmojiEntry[]
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export const EMOJI_CATEGORIES: readonly EmojiCategory[] = [
|
|
22
|
+
{
|
|
23
|
+
name: 'Smileys',
|
|
24
|
+
entries: [
|
|
25
|
+
{ emoji: '😀', keywords: ['sorriso', 'feliz', 'alegre'] },
|
|
26
|
+
{ emoji: '😃', keywords: ['sorriso', 'feliz', 'animado'] },
|
|
27
|
+
{ emoji: '😄', keywords: ['sorriso', 'feliz', 'risada'] },
|
|
28
|
+
{ emoji: '😁', keywords: ['sorriso', 'dentes', 'feliz'] },
|
|
29
|
+
{ emoji: '😅', keywords: ['alivio', 'alívio', 'suor', 'nervoso'] },
|
|
30
|
+
{ emoji: '😂', keywords: ['risada', 'chorando', 'engracado', 'engraçado'] },
|
|
31
|
+
{ emoji: '🤣', keywords: ['risada', 'rolando', 'engracado', 'engraçado'] },
|
|
32
|
+
{ emoji: '😊', keywords: ['sorriso', 'timido', 'tímido', 'feliz'] },
|
|
33
|
+
{ emoji: '😇', keywords: ['anjo', 'inocente', 'santo'] },
|
|
34
|
+
{ emoji: '🙂', keywords: ['sorriso', 'leve', 'ok'] },
|
|
35
|
+
{ emoji: '😉', keywords: ['piscada', 'piscar', 'flerte'] },
|
|
36
|
+
{ emoji: '😌', keywords: ['aliviado', 'calmo', 'tranquilo'] },
|
|
37
|
+
{ emoji: '😍', keywords: ['amor', 'apaixonado', 'coracao', 'coração', 'olhos'] },
|
|
38
|
+
{ emoji: '🥰', keywords: ['amor', 'apaixonado', 'carinho'] },
|
|
39
|
+
{ emoji: '😘', keywords: ['beijo', 'amor', 'carinho'] },
|
|
40
|
+
{ emoji: '😋', keywords: ['gostoso', 'delicia', 'delícia', 'lingua', 'língua'] },
|
|
41
|
+
{ emoji: '😜', keywords: ['lingua', 'língua', 'brincadeira', 'piscada'] },
|
|
42
|
+
{ emoji: '🤪', keywords: ['maluco', 'doido', 'brincadeira'] },
|
|
43
|
+
{ emoji: '🤔', keywords: ['pensando', 'duvida', 'dúvida', 'hmm'] },
|
|
44
|
+
{ emoji: '🤗', keywords: ['abraco', 'abraço', 'carinho'] },
|
|
45
|
+
{ emoji: '😐', keywords: ['neutro', 'serio', 'sério', 'indiferente'] },
|
|
46
|
+
{ emoji: '😴', keywords: ['dormindo', 'sono', 'cansado'] },
|
|
47
|
+
{ emoji: '😭', keywords: ['chorando', 'triste', 'lagrima', 'lágrima'] },
|
|
48
|
+
{ emoji: '😢', keywords: ['triste', 'chorando', 'lagrima', 'lágrima'] },
|
|
49
|
+
{ emoji: '😡', keywords: ['raiva', 'bravo', 'irritado'] },
|
|
50
|
+
{ emoji: '😱', keywords: ['susto', 'medo', 'assustado'] },
|
|
51
|
+
{ emoji: '🤯', keywords: ['explodindo', 'chocado', 'surpresa'] },
|
|
52
|
+
{ emoji: '😎', keywords: ['oculos', 'óculos', 'legal', 'estiloso'] },
|
|
53
|
+
{ emoji: '🥳', keywords: ['festa', 'comemorar', 'aniversario', 'aniversário'] },
|
|
54
|
+
{ emoji: '😷', keywords: ['mascara', 'máscara', 'doente', 'saude', 'saúde'] },
|
|
55
|
+
],
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
name: 'Gestos',
|
|
59
|
+
entries: [
|
|
60
|
+
{ emoji: '👍', keywords: ['joia', 'jóia', 'polegar', 'ok', 'positivo', 'curtir'] },
|
|
61
|
+
{ emoji: '👎', keywords: ['polegar', 'negativo', 'ruim', 'nao', 'não'] },
|
|
62
|
+
{ emoji: '👌', keywords: ['ok', 'certo', 'perfeito'] },
|
|
63
|
+
{ emoji: '✌️', keywords: ['paz', 'vitoria', 'vitória', 'dois'] },
|
|
64
|
+
{ emoji: '🤞', keywords: ['sorte', 'dedos', 'cruzados', 'torcendo'] },
|
|
65
|
+
{ emoji: '🤙', keywords: ['chama', 'ligar', 'shaka'] },
|
|
66
|
+
{ emoji: '👋', keywords: ['tchau', 'ola', 'olá', 'aceno', 'oi'] },
|
|
67
|
+
{ emoji: '✋', keywords: ['mao', 'mão', 'parar', 'pare'] },
|
|
68
|
+
{ emoji: '👏', keywords: ['palmas', 'aplauso', 'parabens', 'parabéns'] },
|
|
69
|
+
{ emoji: '🙌', keywords: ['comemorar', 'maos', 'mãos', 'sucesso'] },
|
|
70
|
+
{ emoji: '🤝', keywords: ['acordo', 'aperto', 'mao', 'mão', 'negocio', 'negócio', 'parceria'] },
|
|
71
|
+
{ emoji: '🙏', keywords: ['obrigado', 'reza', 'oracao', 'oração', 'por favor'] },
|
|
72
|
+
{ emoji: '✍️', keywords: ['escrever', 'assinar', 'assinatura'] },
|
|
73
|
+
{ emoji: '💪', keywords: ['forca', 'força', 'musculo', 'músculo', 'braco', 'braço'] },
|
|
74
|
+
{ emoji: '👇', keywords: ['abaixo', 'baixo', 'apontar', 'seta'] },
|
|
75
|
+
{ emoji: '👉', keywords: ['direita', 'apontar', 'seta'] },
|
|
76
|
+
{ emoji: '☝️', keywords: ['acima', 'cima', 'apontar', 'atencao', 'atenção'] },
|
|
77
|
+
],
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
name: 'Corações',
|
|
81
|
+
entries: [
|
|
82
|
+
{ emoji: '❤️', keywords: ['coracao', 'coração', 'amor', 'vermelho'] },
|
|
83
|
+
{ emoji: '🧡', keywords: ['coracao', 'coração', 'laranja'] },
|
|
84
|
+
{ emoji: '💛', keywords: ['coracao', 'coração', 'amarelo'] },
|
|
85
|
+
{ emoji: '💚', keywords: ['coracao', 'coração', 'verde'] },
|
|
86
|
+
{ emoji: '💙', keywords: ['coracao', 'coração', 'azul'] },
|
|
87
|
+
{ emoji: '💜', keywords: ['coracao', 'coração', 'roxo'] },
|
|
88
|
+
{ emoji: '🖤', keywords: ['coracao', 'coração', 'preto'] },
|
|
89
|
+
{ emoji: '🤍', keywords: ['coracao', 'coração', 'branco'] },
|
|
90
|
+
{ emoji: '💔', keywords: ['coracao', 'coração', 'partido', 'triste'] },
|
|
91
|
+
{ emoji: '💕', keywords: ['coracao', 'coração', 'amor', 'casal'] },
|
|
92
|
+
{ emoji: '💖', keywords: ['coracao', 'coração', 'brilho', 'amor'] },
|
|
93
|
+
{ emoji: '💝', keywords: ['coracao', 'coração', 'presente', 'laco', 'laço'] },
|
|
94
|
+
],
|
|
95
|
+
},
|
|
96
|
+
{
|
|
97
|
+
name: 'Negócios',
|
|
98
|
+
entries: [
|
|
99
|
+
{ emoji: '🏠', keywords: ['casa', 'imovel', 'imóvel', 'residencia', 'residência', 'moradia'] },
|
|
100
|
+
{ emoji: '🏡', keywords: ['casa', 'imovel', 'imóvel', 'jardim', 'moradia'] },
|
|
101
|
+
{ emoji: '🏢', keywords: ['predio', 'prédio', 'empresa', 'escritorio', 'escritório'] },
|
|
102
|
+
{ emoji: '🏦', keywords: ['banco', 'financiamento', 'agencia', 'agência'] },
|
|
103
|
+
{ emoji: '🔑', keywords: ['chave', 'casa', 'entrega', 'imovel', 'imóvel'] },
|
|
104
|
+
{ emoji: '📄', keywords: ['documento', 'papel', 'contrato', 'arquivo'] },
|
|
105
|
+
{ emoji: '📋', keywords: ['prancheta', 'lista', 'documento', 'checklist'] },
|
|
106
|
+
{ emoji: '📝', keywords: ['anotar', 'escrever', 'nota', 'formulario', 'formulário'] },
|
|
107
|
+
{ emoji: '✅', keywords: ['ok', 'certo', 'aprovado', 'concluido', 'concluído', 'check'] },
|
|
108
|
+
{ emoji: '❌', keywords: ['errado', 'negado', 'recusado', 'cancelar'] },
|
|
109
|
+
{ emoji: '⚠️', keywords: ['atencao', 'atenção', 'alerta', 'cuidado'] },
|
|
110
|
+
{ emoji: '💰', keywords: ['dinheiro', 'valor', 'saco', 'grana', 'pagamento'] },
|
|
111
|
+
{ emoji: '💵', keywords: ['dinheiro', 'nota', 'valor', 'pagamento'] },
|
|
112
|
+
{ emoji: '💳', keywords: ['cartao', 'cartão', 'credito', 'crédito', 'pagamento'] },
|
|
113
|
+
{ emoji: '🧾', keywords: ['recibo', 'nota', 'fiscal', 'comprovante'] },
|
|
114
|
+
{ emoji: '📊', keywords: ['grafico', 'gráfico', 'relatorio', 'relatório', 'dados'] },
|
|
115
|
+
{ emoji: '📈', keywords: ['grafico', 'gráfico', 'subindo', 'crescimento', 'alta'] },
|
|
116
|
+
{ emoji: '📉', keywords: ['grafico', 'gráfico', 'caindo', 'queda', 'baixa'] },
|
|
117
|
+
{ emoji: '🗓️', keywords: ['calendario', 'calendário', 'data', 'agenda', 'prazo'] },
|
|
118
|
+
{ emoji: '⏰', keywords: ['relogio', 'relógio', 'hora', 'prazo', 'alarme'] },
|
|
119
|
+
{ emoji: '📞', keywords: ['telefone', 'ligar', 'contato', 'chamada'] },
|
|
120
|
+
{ emoji: '📱', keywords: ['celular', 'telefone', 'whatsapp', 'contato'] },
|
|
121
|
+
{ emoji: '📧', keywords: ['email', 'e-mail', 'mensagem', 'contato'] },
|
|
122
|
+
{ emoji: '📎', keywords: ['anexo', 'clipe', 'arquivo'] },
|
|
123
|
+
{ emoji: '🔍', keywords: ['buscar', 'procurar', 'lupa', 'pesquisa', 'consulta'] },
|
|
124
|
+
{ emoji: '🤖', keywords: ['robo', 'robô', 'bot', 'assistente', 'automatico', 'automático'] },
|
|
125
|
+
{ emoji: '💬', keywords: ['mensagem', 'conversa', 'balao', 'balão', 'chat'] },
|
|
126
|
+
],
|
|
127
|
+
},
|
|
128
|
+
{
|
|
129
|
+
name: 'Objetos',
|
|
130
|
+
entries: [
|
|
131
|
+
{ emoji: '🎁', keywords: ['presente', 'brinde', 'surpresa'] },
|
|
132
|
+
{ emoji: '🎉', keywords: ['festa', 'comemorar', 'parabens', 'parabéns'] },
|
|
133
|
+
{ emoji: '🎊', keywords: ['festa', 'confete', 'comemorar'] },
|
|
134
|
+
{ emoji: '🎂', keywords: ['bolo', 'aniversario', 'aniversário', 'festa'] },
|
|
135
|
+
{ emoji: '💡', keywords: ['ideia', 'ideía', 'lampada', 'lâmpada', 'dica'] },
|
|
136
|
+
{ emoji: '🔔', keywords: ['sino', 'aviso', 'notificacao', 'notificação', 'lembrete'] },
|
|
137
|
+
{ emoji: '⭐', keywords: ['estrela', 'favorito', 'avaliacao', 'avaliação'] },
|
|
138
|
+
{ emoji: '🔥', keywords: ['fogo', 'quente', 'destaque', 'top'] },
|
|
139
|
+
{ emoji: '🚀', keywords: ['foguete', 'rapido', 'rápido', 'lancamento', 'lançamento'] },
|
|
140
|
+
{ emoji: '💻', keywords: ['computador', 'notebook', 'trabalho'] },
|
|
141
|
+
{ emoji: '📷', keywords: ['foto', 'camera', 'câmera', 'imagem'] },
|
|
142
|
+
{ emoji: '🚗', keywords: ['carro', 'veiculo', 'veículo', 'automovel', 'automóvel'] },
|
|
143
|
+
{ emoji: '✈️', keywords: ['aviao', 'avião', 'viagem', 'voo'] },
|
|
144
|
+
],
|
|
145
|
+
},
|
|
146
|
+
{
|
|
147
|
+
name: 'Comida',
|
|
148
|
+
entries: [
|
|
149
|
+
{ emoji: '🍔', keywords: ['hamburguer', 'hambúrguer', 'lanche', 'comida'] },
|
|
150
|
+
{ emoji: '🍕', keywords: ['pizza', 'comida', 'lanche'] },
|
|
151
|
+
{ emoji: '🍟', keywords: ['batata', 'frita', 'lanche'] },
|
|
152
|
+
{ emoji: '🍿', keywords: ['pipoca', 'cinema', 'filme'] },
|
|
153
|
+
{ emoji: '🍞', keywords: ['pao', 'pão', 'padaria'] },
|
|
154
|
+
{ emoji: '🧀', keywords: ['queijo', 'comida'] },
|
|
155
|
+
{ emoji: '🥗', keywords: ['salada', 'saudavel', 'saudável', 'comida'] },
|
|
156
|
+
{ emoji: '☕', keywords: ['cafe', 'café', 'bebida', 'quente'] },
|
|
157
|
+
{ emoji: '🍺', keywords: ['cerveja', 'bebida', 'chopp'] },
|
|
158
|
+
{ emoji: '🍷', keywords: ['vinho', 'bebida', 'taca', 'taça'] },
|
|
159
|
+
{ emoji: '🥂', keywords: ['brinde', 'comemorar', 'champanhe'] },
|
|
160
|
+
{ emoji: '🥤', keywords: ['refrigerante', 'bebida', 'copo'] },
|
|
161
|
+
],
|
|
162
|
+
},
|
|
163
|
+
] as const
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Índice achatado: a busca ignora categoria, porque quem digita "casa" quer o resultado venha de
|
|
167
|
+
* onde vier. A ordem preserva a das categorias, então o resultado sai agrupado por afinidade sem
|
|
168
|
+
* precisar ordenar.
|
|
169
|
+
*/
|
|
170
|
+
const ALL_ENTRIES: readonly EmojiEntry[] = EMOJI_CATEGORIES.flatMap((category) => category.entries)
|
|
171
|
+
|
|
172
|
+
function normalize(value: string): string {
|
|
173
|
+
return value
|
|
174
|
+
.toLowerCase()
|
|
175
|
+
.normalize('NFD')
|
|
176
|
+
.replace(/\p{Diacritic}/gu, '')
|
|
177
|
+
.trim()
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Busca por prefixo de palavra-chave, não por substring: "ca" traz "casa" e "cartão", mas "sa" não
|
|
182
|
+
* traz "casa" — casar no meio da palavra devolvia resultado que ninguém consegue explicar.
|
|
183
|
+
*/
|
|
184
|
+
export function searchEmojis(query: string): readonly EmojiEntry[] {
|
|
185
|
+
const term = normalize(query)
|
|
186
|
+
if (!term) return ALL_ENTRIES
|
|
187
|
+
|
|
188
|
+
return ALL_ENTRIES.filter((entry) => entry.keywords.some((keyword) => normalize(keyword).startsWith(term)))
|
|
189
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { useMemo } from 'react'
|
|
2
2
|
import { ReactFlow, Background, Controls, MarkerType, type Node, type Edge } from '@xyflow/react'
|
|
3
3
|
import '@xyflow/react/dist/style.css'
|
|
4
|
-
import {
|
|
4
|
+
import { useIsDarkTheme } from '../useDarkMode'
|
|
5
5
|
import { flowMapNodeTypes, type FlowMapNodeData } from './FlowMapNode'
|
|
6
6
|
import { computeFlowMapLayout, crossFlowTargetsOf, type FlowGraphData } from './flowGraph'
|
|
7
7
|
import { DEFAULT_FLOW_EDITOR_LABELS, type FlowEditorLabels } from './labels'
|
|
@@ -23,7 +23,7 @@ export interface FlowMapCanvasProps {
|
|
|
23
23
|
// visão hierárquica onde cada fluxo é um único nó, ligado por saltos "flow:<key>".
|
|
24
24
|
export function FlowMapCanvas({ graphs, rootKey, onOpenFlow, labels: labelsOverride }: FlowMapCanvasProps) {
|
|
25
25
|
const labels = { ...DEFAULT_FLOW_EDITOR_LABELS, ...labelsOverride }
|
|
26
|
-
const
|
|
26
|
+
const isDark = useIsDarkTheme()
|
|
27
27
|
const positions = useMemo(() => computeFlowMapLayout(graphs, rootKey), [graphs, rootKey])
|
|
28
28
|
|
|
29
29
|
const nodes = useMemo<Node[]>(
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { useMemo } from 'react'
|
|
2
|
+
import { useConversations } from '../providers/ConversationsProvider'
|
|
3
|
+
import type { ConversationTemplate } from '../providers/types'
|
|
4
|
+
|
|
5
|
+
export interface UseConversationActionsResult {
|
|
6
|
+
/** `undefined` quando a API do host não implementa a operação — a UI esconde a afordância. */
|
|
7
|
+
takeover: (() => Promise<void>) | undefined
|
|
8
|
+
release: (() => Promise<void>) | undefined
|
|
9
|
+
finalize: (() => Promise<void>) | undefined
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Ações de atendimento de UMA conversa, já ligadas ao id.
|
|
14
|
+
*
|
|
15
|
+
* Separado de `useConversationMessages` porque assumir e devolver conversa também acontece a
|
|
16
|
+
* partir da lista, onde nenhuma thread está aberta — embutir nas mensagens obrigaria a carregar
|
|
17
|
+
* a thread inteira só para desenhar um botão na linha.
|
|
18
|
+
*/
|
|
19
|
+
export function useConversationActions(conversationId: string): UseConversationActionsResult {
|
|
20
|
+
const context = useConversations()
|
|
21
|
+
if (!context) {
|
|
22
|
+
throw new Error('useConversationActions requires an ancestor <ConversationsProvider>')
|
|
23
|
+
}
|
|
24
|
+
const { api } = context
|
|
25
|
+
|
|
26
|
+
return useMemo(
|
|
27
|
+
() => ({
|
|
28
|
+
takeover: api.takeover ? () => api.takeover!(conversationId) : undefined,
|
|
29
|
+
release: api.release ? () => api.release!(conversationId) : undefined,
|
|
30
|
+
finalize: api.finalize ? () => api.finalize!(conversationId) : undefined,
|
|
31
|
+
}),
|
|
32
|
+
[api, conversationId],
|
|
33
|
+
)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface UseInboxActionsResult {
|
|
37
|
+
markAllRead: (() => Promise<void>) | undefined
|
|
38
|
+
listTemplates: (() => Promise<ConversationTemplate[]>) | undefined
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Ações que valem para a caixa inteira, sem conversa selecionada. */
|
|
42
|
+
export function useInboxActions(): UseInboxActionsResult {
|
|
43
|
+
const context = useConversations()
|
|
44
|
+
if (!context) {
|
|
45
|
+
throw new Error('useInboxActions requires an ancestor <ConversationsProvider>')
|
|
46
|
+
}
|
|
47
|
+
const { api } = context
|
|
48
|
+
|
|
49
|
+
return useMemo(
|
|
50
|
+
() => ({
|
|
51
|
+
markAllRead: api.markAllRead ? () => api.markAllRead!() : undefined,
|
|
52
|
+
listTemplates: api.listTemplates ? () => api.listTemplates!() : undefined,
|
|
53
|
+
}),
|
|
54
|
+
[api],
|
|
55
|
+
)
|
|
56
|
+
}
|
|
@@ -1,21 +1,23 @@
|
|
|
1
1
|
import { useConversations } from '../providers/ConversationsProvider'
|
|
2
2
|
import { useAsyncResource } from './useAsyncResource'
|
|
3
|
-
import
|
|
3
|
+
import { documentsOf, totalOf } from '../lib/paginated'
|
|
4
|
+
import type { ConversationDocument, ListDocumentsParams } from '../providers/types'
|
|
4
5
|
|
|
5
|
-
export
|
|
6
|
-
search?: string
|
|
7
|
-
page?: number
|
|
8
|
-
}
|
|
6
|
+
export type UseConversationDocumentsParams = ListDocumentsParams
|
|
9
7
|
|
|
10
8
|
export interface UseConversationDocumentsResult {
|
|
11
9
|
documents: ConversationDocument[]
|
|
10
|
+
/** Total no servidor. Cai para o tamanho da página quando a API devolve só o array. */
|
|
11
|
+
total: number
|
|
12
12
|
loading: boolean
|
|
13
13
|
error: Error | undefined
|
|
14
14
|
refetch: () => Promise<void>
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
+
// Aceita `undefined` como "ainda não buscar", igual a `useConversationRealtime`: a lista de anexos
|
|
18
|
+
// é consulta extra, e quem só abre o painel sob demanda não deve pagar por ela em toda conversa.
|
|
17
19
|
export function useConversationDocuments(
|
|
18
|
-
conversationId: string,
|
|
20
|
+
conversationId: string | undefined,
|
|
19
21
|
params?: UseConversationDocumentsParams,
|
|
20
22
|
): UseConversationDocumentsResult {
|
|
21
23
|
const context = useConversations()
|
|
@@ -25,9 +27,13 @@ export function useConversationDocuments(
|
|
|
25
27
|
const { api } = context
|
|
26
28
|
|
|
27
29
|
const { data, loading, error, refetch } = useAsyncResource(
|
|
28
|
-
() => api.getDocuments(conversationId, params),
|
|
29
|
-
[conversationId, params?.search, params?.page],
|
|
30
|
+
() => (conversationId ? api.getDocuments(conversationId, params) : Promise.resolve([])),
|
|
31
|
+
[conversationId, params?.search, params?.page, params?.limit, params?.source, params?.sortDirection],
|
|
30
32
|
)
|
|
31
33
|
|
|
32
|
-
|
|
34
|
+
if (data === undefined) {
|
|
35
|
+
return { documents: [], total: 0, loading, error, refetch }
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return { documents: documentsOf(data), total: totalOf(data), loading, error, refetch }
|
|
33
39
|
}
|