@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.
@@ -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 `capabilitiesOf`, não de constante fixa.
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 { capabilitiesOf, type ConversationChannel } from './conversationChannel'
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 capabilities = capabilitiesOf(params.channel)
56
- if (!capabilities.hasSessionWindow) return CONVERSATION_WINDOW.FRESH
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 = capabilities.windowHours * HOUR_MS
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 { messages: data ?? [], loading, error, refetch, sendMessage, sendMedia, sendTemplate, markRead }
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: capacidades por plataforma (janela de sessão, reabertura, tipo de identificador).
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 messages = params.store.listMessages(conversationId)
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(conversation.id)
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={scroll.handleScroll}
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
- (file): QueuedAttachment => ({ kind: 'local', localId: crypto.randomUUID(), file }),
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) } : {})}
@@ -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',