@adatechnology/conversations-ui 0.3.1 → 0.4.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-DmV8uaFE.d.ts → ConversationSimulatorPanel-DnXElfY8.d.ts} +87 -73
- package/dist/{chunk-L4OE4ORQ.js → chunk-4P5KMWOI.js} +219 -161
- package/dist/index.d.ts +51 -6
- package/dist/index.js +273 -135
- package/dist/preview/index.d.ts +2 -2
- package/dist/preview/index.js +4 -2
- package/dist/styles.css +16 -0
- package/package.json +3 -2
- package/src/AudioRecorderButton.test.tsx +18 -0
- package/src/AudioRecorderButton.tsx +17 -2
- package/src/ChannelIcon.tsx +11 -0
- package/src/MessageBubble.test.tsx +33 -0
- package/src/MessageBubble.tsx +18 -5
- package/src/MessageComposer.test.tsx +39 -1
- package/src/MessageComposer.tsx +66 -15
- package/src/channelCapability.test.ts +47 -0
- package/src/channelCapability.ts +37 -0
- package/src/conversationChannel.ts +19 -10
- package/src/conversationWindow.ts +7 -5
- package/src/hooks/useConversationMessages.test.ts +90 -0
- package/src/hooks/useConversationMessages.ts +116 -2
- package/src/hooks/useLoadOlderMessagesScroll.test.ts +21 -0
- package/src/hooks/useLoadOlderMessagesScroll.ts +76 -0
- package/src/index.ts +5 -1
- package/src/preview/ConversationSimulatorPanel.tsx +4 -0
- package/src/preview/createMockConversationsApi.ts +5 -1
- package/src/styles.css +19 -0
- package/src/workspace/ConversationPane.tsx +32 -5
- package/src/workspace/labels.ts +4 -0
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Janela de sessão: o intervalo em que o canal aceita mensagem livre do atendente. No WhatsApp são
|
|
3
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 `
|
|
4
|
+
* canal sem janela nenhuma — então a política vem de `channelCapabilityFor` (RF10), não de
|
|
5
|
+
* constante fixa.
|
|
5
6
|
*
|
|
6
7
|
* Mora no SDK porque é regra de plataforma, não de produto: todo projeto que usa este pacote
|
|
7
8
|
* precisa dela para saber o que o atendente ainda consegue fazer.
|
|
8
9
|
*/
|
|
9
10
|
|
|
10
|
-
import {
|
|
11
|
+
import { channelCapabilityFor } from './channelCapability'
|
|
12
|
+
import type { ConversationChannel } from './conversationChannel'
|
|
11
13
|
|
|
12
14
|
const MINUTE_MS = 60_000
|
|
13
15
|
const HOUR_MS = 60 * MINUTE_MS
|
|
@@ -52,12 +54,12 @@ export type WindowOfParams = {
|
|
|
52
54
|
* bloquearia o composer inventando um limite que a plataforma não impõe.
|
|
53
55
|
*/
|
|
54
56
|
export function windowOf(params: WindowOfParams): ConversationWindow {
|
|
55
|
-
const
|
|
56
|
-
if (
|
|
57
|
+
const capability = channelCapabilityFor(params.channel)
|
|
58
|
+
if (capability.sessionWindowHours === null) return CONVERSATION_WINDOW.FRESH
|
|
57
59
|
|
|
58
60
|
if (!params.lastInboundAt) return CONVERSATION_WINDOW.EXPIRED
|
|
59
61
|
|
|
60
|
-
const windowMs =
|
|
62
|
+
const windowMs = capability.sessionWindowHours * HOUR_MS
|
|
61
63
|
const elapsed = params.now - new Date(params.lastInboundAt).getTime()
|
|
62
64
|
|
|
63
65
|
if (elapsed >= windowMs) return CONVERSATION_WINDOW.EXPIRED
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test'
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
dedupeMessagesById,
|
|
5
|
+
pageIndicatesMoreOlderMessages,
|
|
6
|
+
prependOlderMessages,
|
|
7
|
+
shouldFetchOlderMessages,
|
|
8
|
+
} from './useConversationMessages'
|
|
9
|
+
import type { MessagePayload } from '../types'
|
|
10
|
+
|
|
11
|
+
function buildMessage(id: string, timestamp: string): MessagePayload {
|
|
12
|
+
return { id, type: 'text', content: id, direction: 'inbound', sender: 'customer', timestamp }
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
describe('dedupeMessagesById', () => {
|
|
16
|
+
it('mantém a primeira ocorrência de cada id e descarta as repetidas', () => {
|
|
17
|
+
const first = buildMessage('1', '2026-09-01T10:00:00.000Z')
|
|
18
|
+
const duplicate = buildMessage('1', '2026-09-01T10:00:00.000Z')
|
|
19
|
+
const second = buildMessage('2', '2026-09-01T10:01:00.000Z')
|
|
20
|
+
|
|
21
|
+
expect(dedupeMessagesById([first, duplicate, second])).toEqual([first, second])
|
|
22
|
+
})
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
describe('prependOlderMessages', () => {
|
|
26
|
+
it('prepende a página anterior descartando o que já estava carregado', () => {
|
|
27
|
+
const accumulated = [buildMessage('3', '2026-09-01T10:02:00.000Z')]
|
|
28
|
+
const olderPage = [buildMessage('1', '2026-09-01T10:00:00.000Z'), buildMessage('2', '2026-09-01T10:01:00.000Z')]
|
|
29
|
+
|
|
30
|
+
expect(prependOlderMessages(accumulated, olderPage)).toEqual([...olderPage, ...accumulated])
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('não duplica quando a mesma página anterior chega de novo', () => {
|
|
34
|
+
const message = buildMessage('1', '2026-09-01T10:00:00.000Z')
|
|
35
|
+
const accumulated = [message]
|
|
36
|
+
|
|
37
|
+
expect(prependOlderMessages(accumulated, [message])).toEqual([message])
|
|
38
|
+
})
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
describe('shouldFetchOlderMessages', () => {
|
|
42
|
+
const oldestMessage = buildMessage('1', '2026-09-01T10:00:00.000Z')
|
|
43
|
+
|
|
44
|
+
it('busca quando não há requisição em voo, ainda há mais páginas e existe uma mensagem mais antiga', () => {
|
|
45
|
+
expect(shouldFetchOlderMessages({ isLoading: false, hasMore: true, oldestMessage })).toBe(true)
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it('não dispara requisição concorrente enquanto uma já está em voo', () => {
|
|
49
|
+
expect(shouldFetchOlderMessages({ isLoading: true, hasMore: true, oldestMessage })).toBe(false)
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('para de buscar quando uma página anterior já veio incompleta (hasMore=false)', () => {
|
|
53
|
+
expect(shouldFetchOlderMessages({ isLoading: false, hasMore: false, oldestMessage })).toBe(false)
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('não busca sem mensagem mais antiga para servir de before', () => {
|
|
57
|
+
expect(shouldFetchOlderMessages({ isLoading: false, hasMore: true, oldestMessage: undefined })).toBe(false)
|
|
58
|
+
})
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
describe('pageIndicatesMoreOlderMessages', () => {
|
|
62
|
+
it('página completa (igual ao limite) indica que pode haver mais', () => {
|
|
63
|
+
expect(pageIndicatesMoreOlderMessages(50, 50)).toBe(true)
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it('página menor que o limite indica que não há mais mensagens antigas', () => {
|
|
67
|
+
expect(pageIndicatesMoreOlderMessages(12, 50)).toBe(false)
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it('página vazia indica que não há mais mensagens antigas', () => {
|
|
71
|
+
expect(pageIndicatesMoreOlderMessages(0, 50)).toBe(false)
|
|
72
|
+
})
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
describe('preservação do histórico ao chegar mensagem nova (SSE/polling)', () => {
|
|
76
|
+
it('combina o acumulado antigo com a janela recente sem perder páginas já carregadas', () => {
|
|
77
|
+
const olderMessages = [buildMessage('1', '2026-09-01T10:00:00.000Z'), buildMessage('2', '2026-09-01T10:01:00.000Z')]
|
|
78
|
+
const recentWindowBeforeNewMessage = [buildMessage('3', '2026-09-01T10:02:00.000Z')]
|
|
79
|
+
const recentWindowAfterNewMessage = [
|
|
80
|
+
buildMessage('3', '2026-09-01T10:02:00.000Z'),
|
|
81
|
+
buildMessage('4', '2026-09-01T10:03:00.000Z'),
|
|
82
|
+
]
|
|
83
|
+
|
|
84
|
+
const before = dedupeMessagesById([...olderMessages, ...recentWindowBeforeNewMessage])
|
|
85
|
+
const after = dedupeMessagesById([...olderMessages, ...recentWindowAfterNewMessage])
|
|
86
|
+
|
|
87
|
+
expect(before.map((message) => message.id)).toEqual(['1', '2', '3'])
|
|
88
|
+
expect(after.map((message) => message.id)).toEqual(['1', '2', '3', '4'])
|
|
89
|
+
})
|
|
90
|
+
})
|
|
@@ -1,8 +1,11 @@
|
|
|
1
|
-
import { useCallback } from 'react'
|
|
1
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|
2
2
|
import { useConversations } from '../providers/ConversationsProvider'
|
|
3
3
|
import { useAsyncResource } from './useAsyncResource'
|
|
4
4
|
import type { MessagePayload } from '../types'
|
|
5
5
|
|
|
6
|
+
/** Tamanho da página ao buscar mensagens anteriores — mesmo padrão do `before` sem `limit`. */
|
|
7
|
+
const OLDER_MESSAGES_PAGE_LIMIT = 50
|
|
8
|
+
|
|
6
9
|
export interface UseConversationMessagesResult {
|
|
7
10
|
messages: MessagePayload[]
|
|
8
11
|
loading: boolean
|
|
@@ -12,6 +15,59 @@ export interface UseConversationMessagesResult {
|
|
|
12
15
|
sendMedia: (data: { base64: string; mimeType: string; filename: string; caption?: string }) => Promise<MessagePayload>
|
|
13
16
|
sendTemplate: (data: { templateName?: string; languageCode?: string; bodyParams?: string[] }) => Promise<void>
|
|
14
17
|
markRead: () => Promise<void>
|
|
18
|
+
/** Busca a página anterior de mensagens e prepende ao histórico já carregado. */
|
|
19
|
+
loadOlderMessages: () => Promise<void>
|
|
20
|
+
loadingOlderMessages: boolean
|
|
21
|
+
/** `false` assim que uma página vier menor que o limite — não há mais o que buscar antes dela. */
|
|
22
|
+
hasMoreOlderMessages: boolean
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Descarta qualquer mensagem cujo `id` já apareceu antes, mantendo a primeira ocorrência. */
|
|
26
|
+
export function dedupeMessagesById(messages: readonly MessagePayload[]): MessagePayload[] {
|
|
27
|
+
const seenIds = new Set<string>()
|
|
28
|
+
const deduped: MessagePayload[] = []
|
|
29
|
+
for (const message of messages) {
|
|
30
|
+
if (seenIds.has(message.id)) continue
|
|
31
|
+
seenIds.add(message.id)
|
|
32
|
+
deduped.push(message)
|
|
33
|
+
}
|
|
34
|
+
return deduped
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Soma uma página de mensagens antigas ao acumulado já carregado, sem duplicar.
|
|
39
|
+
*
|
|
40
|
+
* Duplicar aconteceria numa corrida onde a mesma página chegasse duas vezes — o `id` é a única
|
|
41
|
+
* garantia de identidade da mensagem, o timestamp usado no `before` não basta sozinho.
|
|
42
|
+
*/
|
|
43
|
+
export function prependOlderMessages(
|
|
44
|
+
accumulated: readonly MessagePayload[],
|
|
45
|
+
olderPage: readonly MessagePayload[],
|
|
46
|
+
): MessagePayload[] {
|
|
47
|
+
const existingIds = new Set(accumulated.map((message) => message.id))
|
|
48
|
+
const newMessages = olderPage.filter((message) => !existingIds.has(message.id))
|
|
49
|
+
return [...newMessages, ...accumulated]
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export type LoadOlderMessagesGuardState = {
|
|
53
|
+
readonly isLoading: boolean
|
|
54
|
+
readonly hasMore: boolean
|
|
55
|
+
readonly oldestMessage: MessagePayload | undefined
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Decide se vale a pena buscar a página anterior: nunca com uma busca já em voo (evita requisição
|
|
60
|
+
* concorrente), nunca depois que uma página já veio incompleta, e nunca sem uma mensagem mais
|
|
61
|
+
* antiga para servir de `before`.
|
|
62
|
+
*/
|
|
63
|
+
export function shouldFetchOlderMessages(state: LoadOlderMessagesGuardState): boolean {
|
|
64
|
+
if (state.isLoading || !state.hasMore) return false
|
|
65
|
+
return Boolean(state.oldestMessage)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** A página veio menor que o limite pedido: não há mais nada antes dela para buscar. */
|
|
69
|
+
export function pageIndicatesMoreOlderMessages(pageLength: number, limit: number): boolean {
|
|
70
|
+
return pageLength >= limit
|
|
15
71
|
}
|
|
16
72
|
|
|
17
73
|
// Camada headless: dados + ações de uma conversa, sem nenhuma tela acoplada — o produto
|
|
@@ -32,6 +88,52 @@ export function useConversationMessages(
|
|
|
32
88
|
[conversationId, params?.limit, params?.before],
|
|
33
89
|
)
|
|
34
90
|
|
|
91
|
+
const [olderMessages, setOlderMessages] = useState<MessagePayload[]>([])
|
|
92
|
+
const [hasMoreOlderMessages, setHasMoreOlderMessages] = useState(true)
|
|
93
|
+
const [loadingOlderMessages, setLoadingOlderMessages] = useState(false)
|
|
94
|
+
/**
|
|
95
|
+
* Espelha `loadingOlderMessages`: o guarda de concorrência precisa ler o valor atual na hora do
|
|
96
|
+
* clique/scroll, e o estado do React só reflete depois do próximo render.
|
|
97
|
+
*/
|
|
98
|
+
const loadingOlderMessagesRef = useRef(false)
|
|
99
|
+
|
|
100
|
+
// Trocar de conversa zera a paginação de mensagens antigas — a anterior podia já ter esgotado o
|
|
101
|
+
// histórico, e isso não pode vazar para a conversa que acabou de abrir.
|
|
102
|
+
useEffect(() => {
|
|
103
|
+
setOlderMessages([])
|
|
104
|
+
setHasMoreOlderMessages(true)
|
|
105
|
+
}, [conversationId])
|
|
106
|
+
|
|
107
|
+
const messages = useMemo(() => dedupeMessagesById([...olderMessages, ...(data ?? [])]), [olderMessages, data])
|
|
108
|
+
|
|
109
|
+
const loadOlderMessages = useCallback(async () => {
|
|
110
|
+
const oldestMessage = messages[0]
|
|
111
|
+
if (
|
|
112
|
+
!shouldFetchOlderMessages({
|
|
113
|
+
isLoading: loadingOlderMessagesRef.current,
|
|
114
|
+
hasMore: hasMoreOlderMessages,
|
|
115
|
+
oldestMessage,
|
|
116
|
+
})
|
|
117
|
+
)
|
|
118
|
+
return
|
|
119
|
+
// `shouldFetchOlderMessages` já garante isto — repetido aqui só para o compilador estreitar o tipo.
|
|
120
|
+
if (!oldestMessage) return
|
|
121
|
+
|
|
122
|
+
loadingOlderMessagesRef.current = true
|
|
123
|
+
setLoadingOlderMessages(true)
|
|
124
|
+
try {
|
|
125
|
+
const olderPage = await api.fetchMessages(conversationId, {
|
|
126
|
+
limit: OLDER_MESSAGES_PAGE_LIMIT,
|
|
127
|
+
before: oldestMessage.timestamp,
|
|
128
|
+
})
|
|
129
|
+
if (!pageIndicatesMoreOlderMessages(olderPage.length, OLDER_MESSAGES_PAGE_LIMIT)) setHasMoreOlderMessages(false)
|
|
130
|
+
setOlderMessages((current) => prependOlderMessages(current, olderPage))
|
|
131
|
+
} finally {
|
|
132
|
+
loadingOlderMessagesRef.current = false
|
|
133
|
+
setLoadingOlderMessages(false)
|
|
134
|
+
}
|
|
135
|
+
}, [api, conversationId, hasMoreOlderMessages, messages])
|
|
136
|
+
|
|
35
137
|
const sendMessage = useCallback(
|
|
36
138
|
async (text: string) => {
|
|
37
139
|
const message = await api.sendMessage(conversationId, text)
|
|
@@ -60,5 +162,17 @@ export function useConversationMessages(
|
|
|
60
162
|
|
|
61
163
|
const markRead = useCallback(() => api.markRead(conversationId), [api, conversationId])
|
|
62
164
|
|
|
63
|
-
return {
|
|
165
|
+
return {
|
|
166
|
+
messages,
|
|
167
|
+
loading,
|
|
168
|
+
error,
|
|
169
|
+
refetch,
|
|
170
|
+
sendMessage,
|
|
171
|
+
sendMedia,
|
|
172
|
+
sendTemplate,
|
|
173
|
+
markRead,
|
|
174
|
+
loadOlderMessages,
|
|
175
|
+
loadingOlderMessages,
|
|
176
|
+
hasMoreOlderMessages,
|
|
177
|
+
}
|
|
64
178
|
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test'
|
|
2
|
+
|
|
3
|
+
import { computeScrollTopAfterPrepend } from './useLoadOlderMessagesScroll'
|
|
4
|
+
|
|
5
|
+
describe('computeScrollTopAfterPrepend', () => {
|
|
6
|
+
it('mantém o mesmo ponto visual somando ao scrollTop a altura que o conteúdo antigo acrescentou', () => {
|
|
7
|
+
// Container tinha 2000px de conteúdo, operador em 300px do topo; a página anterior somou 800px.
|
|
8
|
+
const before = { scrollHeight: 2000, scrollTop: 300 }
|
|
9
|
+
expect(computeScrollTopAfterPrepend(before, 2800)).toBe(1100)
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
it('não desloca a rolagem quando a altura não muda', () => {
|
|
13
|
+
const before = { scrollHeight: 2000, scrollTop: 300 }
|
|
14
|
+
expect(computeScrollTopAfterPrepend(before, 2000)).toBe(300)
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
it('funciona a partir do topo absoluto (scrollTop=0)', () => {
|
|
18
|
+
const before = { scrollHeight: 2000, scrollTop: 0 }
|
|
19
|
+
expect(computeScrollTopAfterPrepend(before, 2500)).toBe(500)
|
|
20
|
+
})
|
|
21
|
+
})
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { useCallback, useLayoutEffect, useRef, type RefObject, type UIEvent } from 'react'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Chegar perto do topo do transcript dispara a busca da página anterior, e a posição de rolagem é
|
|
5
|
+
* recalculada depois que o DOM cresce por cima do ponto atual — sem isso, prepender mensagens
|
|
6
|
+
* empurra visualmente tudo para baixo e o operador perde a linha onde estava lendo.
|
|
7
|
+
*/
|
|
8
|
+
const NEAR_TOP_THRESHOLD_PX = 200
|
|
9
|
+
|
|
10
|
+
export type ScrollHeightSnapshot = {
|
|
11
|
+
readonly scrollHeight: number
|
|
12
|
+
readonly scrollTop: number
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Novo `scrollTop` que mantém o mesmo ponto visual depois que o conteúdo cresceu por cima. */
|
|
16
|
+
export function computeScrollTopAfterPrepend(before: ScrollHeightSnapshot, scrollHeightAfter: number): number {
|
|
17
|
+
return before.scrollTop + (scrollHeightAfter - before.scrollHeight)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export type UseLoadOlderMessagesScrollParams = {
|
|
21
|
+
readonly containerRef: RefObject<HTMLDivElement | null>
|
|
22
|
+
/** `id` da mensagem mais antiga já carregada — muda quando uma página nova é prependida. */
|
|
23
|
+
readonly oldestMessageId: string | undefined
|
|
24
|
+
readonly hasMore: boolean
|
|
25
|
+
readonly loading: boolean
|
|
26
|
+
readonly onLoadOlder: () => void
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export type UseLoadOlderMessagesScrollResult = {
|
|
30
|
+
/** Ligue no `onScroll` do mesmo elemento que `containerRef`. */
|
|
31
|
+
readonly handleScroll: (event: UIEvent<HTMLDivElement>) => void
|
|
32
|
+
/** Para o botão "Carregar mensagens anteriores" — mesma lógica do gatilho por scroll. */
|
|
33
|
+
readonly triggerLoadOlder: () => void
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function useLoadOlderMessagesScroll({
|
|
37
|
+
containerRef,
|
|
38
|
+
oldestMessageId,
|
|
39
|
+
hasMore,
|
|
40
|
+
loading,
|
|
41
|
+
onLoadOlder,
|
|
42
|
+
}: UseLoadOlderMessagesScrollParams): UseLoadOlderMessagesScrollResult {
|
|
43
|
+
const pendingSnapshotRef = useRef<ScrollHeightSnapshot | null>(null)
|
|
44
|
+
const previousOldestMessageIdRef = useRef(oldestMessageId)
|
|
45
|
+
|
|
46
|
+
const triggerLoadOlder = useCallback(() => {
|
|
47
|
+
if (loading || !hasMore) return
|
|
48
|
+
const container = containerRef.current
|
|
49
|
+
if (!container) return
|
|
50
|
+
|
|
51
|
+
pendingSnapshotRef.current = { scrollHeight: container.scrollHeight, scrollTop: container.scrollTop }
|
|
52
|
+
onLoadOlder()
|
|
53
|
+
}, [containerRef, hasMore, loading, onLoadOlder])
|
|
54
|
+
|
|
55
|
+
const handleScroll = useCallback(
|
|
56
|
+
(event: UIEvent<HTMLDivElement>) => {
|
|
57
|
+
if (event.currentTarget.scrollTop > NEAR_TOP_THRESHOLD_PX) return
|
|
58
|
+
triggerLoadOlder()
|
|
59
|
+
},
|
|
60
|
+
[triggerLoadOlder],
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
useLayoutEffect(() => {
|
|
64
|
+
const idChanged = previousOldestMessageIdRef.current !== oldestMessageId
|
|
65
|
+
previousOldestMessageIdRef.current = oldestMessageId
|
|
66
|
+
|
|
67
|
+
const container = containerRef.current
|
|
68
|
+
const pending = pendingSnapshotRef.current
|
|
69
|
+
if (!idChanged || !container || !pending) return
|
|
70
|
+
|
|
71
|
+
pendingSnapshotRef.current = null
|
|
72
|
+
container.scrollTop = computeScrollTopAfterPrepend(pending, container.scrollHeight)
|
|
73
|
+
}, [oldestMessageId, containerRef])
|
|
74
|
+
|
|
75
|
+
return { handleScroll, triggerLoadOlder }
|
|
76
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -53,7 +53,7 @@ export {
|
|
|
53
53
|
isReplyOverdue,
|
|
54
54
|
} from './replyLatency'
|
|
55
55
|
export type { ReplyLatency, ReplyLatencyParams } from './replyLatency'
|
|
56
|
-
// Canal de origem:
|
|
56
|
+
// Canal de origem: exibição por plataforma (rótulo, ícone, reabertura, tipo de identificador).
|
|
57
57
|
export {
|
|
58
58
|
CONVERSATION_CHANNEL,
|
|
59
59
|
DEFAULT_CONVERSATION_CHANNEL,
|
|
@@ -75,6 +75,10 @@ export type {
|
|
|
75
75
|
HandleKind,
|
|
76
76
|
FormatContactHandleParams,
|
|
77
77
|
} from './conversationChannel'
|
|
78
|
+
// Capacidade comportamental do canal (RF10, D3) — confirma leitura, janela, anexo, áudio, resposta
|
|
79
|
+
// rápida, transporte próprio. Vem de `@adatechnology/conversation-contracts`; ver channelCapability.ts.
|
|
80
|
+
export { channelCapabilityFor } from './channelCapability'
|
|
81
|
+
export type { ChannelCapability } from '@adatechnology/conversation-contracts'
|
|
78
82
|
export type { ConversationWindow } from './conversationWindow'
|
|
79
83
|
export { ConversationRow } from './ConversationRow'
|
|
80
84
|
export { ChannelIcon, CHANNEL_BRAND_COLOR } from './ChannelIcon'
|
|
@@ -66,6 +66,10 @@ const SIMULATOR_PANEL_CHANNEL_WORDING: Readonly<Record<ConversationChannel, Chan
|
|
|
66
66
|
destinationHint: 'entrega na API do chat do site',
|
|
67
67
|
placeholder: 'Escreva como o visitante…',
|
|
68
68
|
},
|
|
69
|
+
[CONVERSATION_CHANNEL.EMAIL]: {
|
|
70
|
+
destinationHint: 'entrega na caixa de entrada do contratante',
|
|
71
|
+
placeholder: 'Escreva como quem respondeu o e-mail…',
|
|
72
|
+
},
|
|
69
73
|
}
|
|
70
74
|
|
|
71
75
|
/** Rótulos do painel já resolvidos para o canal — útil para o host que monta o cabeçalho por fora. */
|
|
@@ -108,7 +108,11 @@ export function createMockConversationsApi(params: CreateMockConversationsApiPar
|
|
|
108
108
|
|
|
109
109
|
fetchMessages(conversationId, fetchParams): Promise<MessagePayload[]> {
|
|
110
110
|
return withLatency(() => {
|
|
111
|
-
const
|
|
111
|
+
const allMessages = params.store.listMessages(conversationId)
|
|
112
|
+
const before = fetchParams?.before
|
|
113
|
+
// Mesma semântica do backend real: com `before`, corta tudo a partir dessa mensagem (ela
|
|
114
|
+
// não volta) para simular a paginação de "carregar mensagens anteriores" no preview.
|
|
115
|
+
const messages = before ? allMessages.filter((message) => message.timestamp < before) : allMessages
|
|
112
116
|
const limit = fetchParams?.limit
|
|
113
117
|
return limit ? messages.slice(-limit) : messages
|
|
114
118
|
})
|
package/src/styles.css
CHANGED
|
@@ -482,6 +482,25 @@
|
|
|
482
482
|
color: #6b7280;
|
|
483
483
|
}
|
|
484
484
|
|
|
485
|
+
.cv-workspace-load-older {
|
|
486
|
+
display: flex;
|
|
487
|
+
justify-content: center;
|
|
488
|
+
padding: 0.5rem 0;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
.cv-workspace-load-older button {
|
|
492
|
+
min-height: 2.75rem;
|
|
493
|
+
border-radius: 0.375rem;
|
|
494
|
+
border: 1px solid var(--cv-border, #e5e7eb);
|
|
495
|
+
padding: 0 0.75rem;
|
|
496
|
+
font-size: 0.8125rem;
|
|
497
|
+
color: #6b7280;
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
.cv-workspace-load-older button:disabled {
|
|
501
|
+
opacity: 0.6;
|
|
502
|
+
}
|
|
503
|
+
|
|
485
504
|
.dark .cv-workspace-notice {
|
|
486
505
|
border-top-color: #374151;
|
|
487
506
|
background: #1f2937;
|
|
@@ -22,6 +22,7 @@ import { buildTranscriptFilename, buildTranscriptText, downloadTextFile } from '
|
|
|
22
22
|
import { useConversationContext } from '../hooks/useConversationContext'
|
|
23
23
|
import { useConversationMessages } from '../hooks/useConversationMessages'
|
|
24
24
|
import { useConversationRealtime } from '../hooks/useConversationRealtime'
|
|
25
|
+
import { useLoadOlderMessagesScroll } from '../hooks/useLoadOlderMessagesScroll'
|
|
25
26
|
import { useScrollToLatestMessage } from '../hooks/useScrollToLatestMessage'
|
|
26
27
|
import { useConversations } from '../providers/ConversationsProvider'
|
|
27
28
|
import type { ConversationSummary } from '../providers/types'
|
|
@@ -151,7 +152,9 @@ export function ConversationPane({
|
|
|
151
152
|
}
|
|
152
153
|
const { api } = context
|
|
153
154
|
|
|
154
|
-
const { messages, refetch } = useConversationMessages(
|
|
155
|
+
const { messages, refetch, loadOlderMessages, loadingOlderMessages, hasMoreOlderMessages } = useConversationMessages(
|
|
156
|
+
conversation.id,
|
|
157
|
+
)
|
|
155
158
|
const { context: conversationContext } = useConversationContext(conversation.id)
|
|
156
159
|
const [documentsOpen, setDocumentsOpen] = useState(false)
|
|
157
160
|
const [sendFailure, setSendFailure] = useState<string | undefined>(undefined)
|
|
@@ -230,6 +233,16 @@ export function ConversationPane({
|
|
|
230
233
|
// interessa. O hook salta ao trocar de conversa sem arrastar quem estiver lendo o histórico.
|
|
231
234
|
const scroll = useScrollToLatestMessage({ conversationId: conversation.id, messageCount: messages.length })
|
|
232
235
|
|
|
236
|
+
// Chegar perto do topo busca a página anterior e devolve a rolagem ao mesmo ponto visual — sem
|
|
237
|
+
// isso, prepender mensagens antigas empurraria o operador para o meio do histórico.
|
|
238
|
+
const loadOlder = useLoadOlderMessagesScroll({
|
|
239
|
+
containerRef: scroll.containerRef,
|
|
240
|
+
oldestMessageId: messages[0]?.id,
|
|
241
|
+
hasMore: hasMoreOlderMessages,
|
|
242
|
+
loading: loadingOlderMessages,
|
|
243
|
+
onLoadOlder: () => void loadOlderMessages(),
|
|
244
|
+
})
|
|
245
|
+
|
|
233
246
|
// O evento traz só `{ direction, sender }` — quem tem o conteúdo é a query.
|
|
234
247
|
useConversationRealtime(conversation.id, () => {
|
|
235
248
|
void refetch()
|
|
@@ -352,9 +365,19 @@ export function ConversationPane({
|
|
|
352
365
|
mesma aparência, senão o preview deixa de ser referência confiável. */}
|
|
353
366
|
<ConversationWallpaper
|
|
354
367
|
ref={scroll.containerRef}
|
|
355
|
-
onScroll={
|
|
368
|
+
onScroll={(event) => {
|
|
369
|
+
scroll.handleScroll(event)
|
|
370
|
+
loadOlder.handleScroll(event)
|
|
371
|
+
}}
|
|
356
372
|
className="cv-workspace-transcript"
|
|
357
373
|
>
|
|
374
|
+
{hasMoreOlderMessages ? (
|
|
375
|
+
<div className="cv-workspace-load-older">
|
|
376
|
+
<button type="button" onClick={loadOlder.triggerLoadOlder} disabled={loadingOlderMessages}>
|
|
377
|
+
{loadingOlderMessages ? labels.loadingOlderMessages : labels.loadOlderMessages}
|
|
378
|
+
</button>
|
|
379
|
+
</div>
|
|
380
|
+
) : null}
|
|
358
381
|
{messages.map((message, index) => {
|
|
359
382
|
const previous = index > 0 ? messages[index - 1] : undefined
|
|
360
383
|
const startsNewDay =
|
|
@@ -366,6 +389,7 @@ export function ConversationPane({
|
|
|
366
389
|
<MessageBubble
|
|
367
390
|
message={message}
|
|
368
391
|
isMine={message.direction === 'outbound'}
|
|
392
|
+
channel={conversation.channel}
|
|
369
393
|
isFirstInGroup={!previous || previous.sender !== message.sender}
|
|
370
394
|
{...(messageSelection
|
|
371
395
|
? {
|
|
@@ -424,9 +448,11 @@ export function ConversationPane({
|
|
|
424
448
|
? {
|
|
425
449
|
onAttachFiles: (files: FileList) =>
|
|
426
450
|
enqueueAttachments(
|
|
427
|
-
Array.from(files).map(
|
|
428
|
-
|
|
429
|
-
|
|
451
|
+
Array.from(files).map((file): QueuedAttachment => ({
|
|
452
|
+
kind: 'local',
|
|
453
|
+
localId: crypto.randomUUID(),
|
|
454
|
+
file,
|
|
455
|
+
})),
|
|
430
456
|
),
|
|
431
457
|
}
|
|
432
458
|
: onAttach
|
|
@@ -482,6 +508,7 @@ export function ConversationPane({
|
|
|
482
508
|
value={draft}
|
|
483
509
|
onChange={setDraft}
|
|
484
510
|
onSend={(text) => void handleSend(text)}
|
|
511
|
+
channel={conversation.channel}
|
|
485
512
|
// Habilita clipe E microfone: o composer desenha o gravador sozinho quando existe um jeito
|
|
486
513
|
// de entregar arquivo, porque áudio gravado é um anexo como qualquer outro.
|
|
487
514
|
{...(onAttach ? { onAttach: (file: File) => void handleAttach(file) } : {})}
|
package/src/workspace/labels.ts
CHANGED
|
@@ -32,6 +32,8 @@ export interface ConversationsWorkspaceLabels {
|
|
|
32
32
|
readonly templateModalSend: (count: number) => string
|
|
33
33
|
readonly messagesSelected: (count: number) => string
|
|
34
34
|
readonly copySelected: string
|
|
35
|
+
readonly loadOlderMessages: string
|
|
36
|
+
readonly loadingOlderMessages: string
|
|
35
37
|
readonly composerPlaceholder: string
|
|
36
38
|
readonly attachFailure: string
|
|
37
39
|
/** Tira um arquivo da fila antes de enviar. */
|
|
@@ -81,6 +83,8 @@ export const DEFAULT_CONVERSATIONS_WORKSPACE_LABELS: ConversationsWorkspaceLabel
|
|
|
81
83
|
templateModalSend: (count) => `Enviar para ${count}`,
|
|
82
84
|
messagesSelected: (count) => `${count} mensagem${count === 1 ? '' : 's'} selecionada${count === 1 ? '' : 's'}`,
|
|
83
85
|
copySelected: 'Copiar',
|
|
86
|
+
loadOlderMessages: 'Carregar mensagens anteriores',
|
|
87
|
+
loadingOlderMessages: 'Carregando…',
|
|
84
88
|
composerPlaceholder: 'Responder como atendente…',
|
|
85
89
|
attachFailure: 'Falha ao enviar o arquivo.',
|
|
86
90
|
attachmentRemove: 'Remover anexo',
|