@adatechnology/conversations-ui 0.3.1 → 0.3.2
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/index.d.ts +7 -0
- package/dist/index.js +257 -130
- package/dist/preview/index.js +3 -1
- package/dist/styles.css +16 -0
- package/package.json +1 -1
- 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/preview/createMockConversationsApi.ts +5 -1
- package/src/styles.css +19 -0
- package/src/workspace/ConversationPane.tsx +30 -5
- package/src/workspace/labels.ts +4 -0
package/dist/index.d.ts
CHANGED
|
@@ -1643,6 +1643,11 @@ interface UseConversationMessagesResult {
|
|
|
1643
1643
|
bodyParams?: string[];
|
|
1644
1644
|
}) => Promise<void>;
|
|
1645
1645
|
markRead: () => Promise<void>;
|
|
1646
|
+
/** Busca a página anterior de mensagens e prepende ao histórico já carregado. */
|
|
1647
|
+
loadOlderMessages: () => Promise<void>;
|
|
1648
|
+
loadingOlderMessages: boolean;
|
|
1649
|
+
/** `false` assim que uma página vier menor que o limite — não há mais o que buscar antes dela. */
|
|
1650
|
+
hasMoreOlderMessages: boolean;
|
|
1646
1651
|
}
|
|
1647
1652
|
declare function useConversationMessages(conversationId: string, params?: {
|
|
1648
1653
|
limit?: number;
|
|
@@ -1941,6 +1946,8 @@ interface ConversationsWorkspaceLabels {
|
|
|
1941
1946
|
readonly templateModalSend: (count: number) => string;
|
|
1942
1947
|
readonly messagesSelected: (count: number) => string;
|
|
1943
1948
|
readonly copySelected: string;
|
|
1949
|
+
readonly loadOlderMessages: string;
|
|
1950
|
+
readonly loadingOlderMessages: string;
|
|
1944
1951
|
readonly composerPlaceholder: string;
|
|
1945
1952
|
readonly attachFailure: string;
|
|
1946
1953
|
/** Tira um arquivo da fila antes de enviar. */
|
package/dist/index.js
CHANGED
|
@@ -4040,7 +4040,30 @@ function MessagesWorkspace({
|
|
|
4040
4040
|
}
|
|
4041
4041
|
|
|
4042
4042
|
// src/hooks/useConversationMessages.ts
|
|
4043
|
-
import { useCallback as useCallback7 } from "react";
|
|
4043
|
+
import { useCallback as useCallback7, useEffect as useEffect10, useMemo as useMemo3, useRef as useRef7, useState as useState15 } from "react";
|
|
4044
|
+
var OLDER_MESSAGES_PAGE_LIMIT = 50;
|
|
4045
|
+
function dedupeMessagesById(messages) {
|
|
4046
|
+
const seenIds = /* @__PURE__ */ new Set();
|
|
4047
|
+
const deduped = [];
|
|
4048
|
+
for (const message of messages) {
|
|
4049
|
+
if (seenIds.has(message.id)) continue;
|
|
4050
|
+
seenIds.add(message.id);
|
|
4051
|
+
deduped.push(message);
|
|
4052
|
+
}
|
|
4053
|
+
return deduped;
|
|
4054
|
+
}
|
|
4055
|
+
function prependOlderMessages(accumulated, olderPage) {
|
|
4056
|
+
const existingIds = new Set(accumulated.map((message) => message.id));
|
|
4057
|
+
const newMessages = olderPage.filter((message) => !existingIds.has(message.id));
|
|
4058
|
+
return [...newMessages, ...accumulated];
|
|
4059
|
+
}
|
|
4060
|
+
function shouldFetchOlderMessages(state) {
|
|
4061
|
+
if (state.isLoading || !state.hasMore) return false;
|
|
4062
|
+
return Boolean(state.oldestMessage);
|
|
4063
|
+
}
|
|
4064
|
+
function pageIndicatesMoreOlderMessages(pageLength, limit) {
|
|
4065
|
+
return pageLength >= limit;
|
|
4066
|
+
}
|
|
4044
4067
|
function useConversationMessages(conversationId, params) {
|
|
4045
4068
|
const context = useConversations();
|
|
4046
4069
|
if (!context) {
|
|
@@ -4051,6 +4074,38 @@ function useConversationMessages(conversationId, params) {
|
|
|
4051
4074
|
() => api.fetchMessages(conversationId, params),
|
|
4052
4075
|
[conversationId, params?.limit, params?.before]
|
|
4053
4076
|
);
|
|
4077
|
+
const [olderMessages, setOlderMessages] = useState15([]);
|
|
4078
|
+
const [hasMoreOlderMessages, setHasMoreOlderMessages] = useState15(true);
|
|
4079
|
+
const [loadingOlderMessages, setLoadingOlderMessages] = useState15(false);
|
|
4080
|
+
const loadingOlderMessagesRef = useRef7(false);
|
|
4081
|
+
useEffect10(() => {
|
|
4082
|
+
setOlderMessages([]);
|
|
4083
|
+
setHasMoreOlderMessages(true);
|
|
4084
|
+
}, [conversationId]);
|
|
4085
|
+
const messages = useMemo3(() => dedupeMessagesById([...olderMessages, ...data ?? []]), [olderMessages, data]);
|
|
4086
|
+
const loadOlderMessages = useCallback7(async () => {
|
|
4087
|
+
const oldestMessage = messages[0];
|
|
4088
|
+
if (!shouldFetchOlderMessages({
|
|
4089
|
+
isLoading: loadingOlderMessagesRef.current,
|
|
4090
|
+
hasMore: hasMoreOlderMessages,
|
|
4091
|
+
oldestMessage
|
|
4092
|
+
}))
|
|
4093
|
+
return;
|
|
4094
|
+
if (!oldestMessage) return;
|
|
4095
|
+
loadingOlderMessagesRef.current = true;
|
|
4096
|
+
setLoadingOlderMessages(true);
|
|
4097
|
+
try {
|
|
4098
|
+
const olderPage = await api.fetchMessages(conversationId, {
|
|
4099
|
+
limit: OLDER_MESSAGES_PAGE_LIMIT,
|
|
4100
|
+
before: oldestMessage.timestamp
|
|
4101
|
+
});
|
|
4102
|
+
if (!pageIndicatesMoreOlderMessages(olderPage.length, OLDER_MESSAGES_PAGE_LIMIT)) setHasMoreOlderMessages(false);
|
|
4103
|
+
setOlderMessages((current) => prependOlderMessages(current, olderPage));
|
|
4104
|
+
} finally {
|
|
4105
|
+
loadingOlderMessagesRef.current = false;
|
|
4106
|
+
setLoadingOlderMessages(false);
|
|
4107
|
+
}
|
|
4108
|
+
}, [api, conversationId, hasMoreOlderMessages, messages]);
|
|
4054
4109
|
const sendMessage = useCallback7(
|
|
4055
4110
|
async (text) => {
|
|
4056
4111
|
const message = await api.sendMessage(conversationId, text);
|
|
@@ -4075,20 +4130,32 @@ function useConversationMessages(conversationId, params) {
|
|
|
4075
4130
|
[api, conversationId, refetch]
|
|
4076
4131
|
);
|
|
4077
4132
|
const markRead = useCallback7(() => api.markRead(conversationId), [api, conversationId]);
|
|
4078
|
-
return {
|
|
4133
|
+
return {
|
|
4134
|
+
messages,
|
|
4135
|
+
loading,
|
|
4136
|
+
error,
|
|
4137
|
+
refetch,
|
|
4138
|
+
sendMessage,
|
|
4139
|
+
sendMedia,
|
|
4140
|
+
sendTemplate,
|
|
4141
|
+
markRead,
|
|
4142
|
+
loadOlderMessages,
|
|
4143
|
+
loadingOlderMessages,
|
|
4144
|
+
hasMoreOlderMessages
|
|
4145
|
+
};
|
|
4079
4146
|
}
|
|
4080
4147
|
|
|
4081
4148
|
// src/hooks/useScrollToLatestMessage.ts
|
|
4082
|
-
import { useCallback as useCallback8, useLayoutEffect, useRef as
|
|
4149
|
+
import { useCallback as useCallback8, useLayoutEffect, useRef as useRef8, useState as useState16 } from "react";
|
|
4083
4150
|
var NEAR_BOTTOM_THRESHOLD_PX = 120;
|
|
4084
4151
|
var SCROLL_BEHAVIOR = "auto";
|
|
4085
4152
|
function useScrollToLatestMessage({
|
|
4086
4153
|
conversationId,
|
|
4087
4154
|
messageCount
|
|
4088
4155
|
}) {
|
|
4089
|
-
const containerRef =
|
|
4090
|
-
const [isAwayFromBottom, setIsAwayFromBottom] =
|
|
4091
|
-
const isAwayFromBottomRef =
|
|
4156
|
+
const containerRef = useRef8(null);
|
|
4157
|
+
const [isAwayFromBottom, setIsAwayFromBottom] = useState16(false);
|
|
4158
|
+
const isAwayFromBottomRef = useRef8(false);
|
|
4092
4159
|
const scrollToBottom = useCallback8((behavior = SCROLL_BEHAVIOR) => {
|
|
4093
4160
|
const container = containerRef.current;
|
|
4094
4161
|
if (!container) return;
|
|
@@ -4101,7 +4168,7 @@ function useScrollToLatestMessage({
|
|
|
4101
4168
|
isAwayFromBottomRef.current = away;
|
|
4102
4169
|
setIsAwayFromBottom((current) => current === away ? current : away);
|
|
4103
4170
|
}, []);
|
|
4104
|
-
const jumpedForConversationRef =
|
|
4171
|
+
const jumpedForConversationRef = useRef8(void 0);
|
|
4105
4172
|
useLayoutEffect(() => {
|
|
4106
4173
|
const isNewConversation = jumpedForConversationRef.current !== conversationId;
|
|
4107
4174
|
if (isNewConversation) {
|
|
@@ -4148,12 +4215,12 @@ function useConversationContext(conversationId) {
|
|
|
4148
4215
|
}
|
|
4149
4216
|
|
|
4150
4217
|
// src/hooks/useConversationRealtime.ts
|
|
4151
|
-
import { useEffect as
|
|
4218
|
+
import { useEffect as useEffect11, useRef as useRef9 } from "react";
|
|
4152
4219
|
function useConversationRealtime(conversationId, onEvent) {
|
|
4153
4220
|
const sse = useConversations()?.sse;
|
|
4154
|
-
const onEventRef =
|
|
4221
|
+
const onEventRef = useRef9(onEvent);
|
|
4155
4222
|
onEventRef.current = onEvent;
|
|
4156
|
-
|
|
4223
|
+
useEffect11(() => {
|
|
4157
4224
|
if (!sse || !conversationId) return;
|
|
4158
4225
|
const source = sse.connectConversationStream(conversationId);
|
|
4159
4226
|
const handler = (event) => onEventRef.current(event);
|
|
@@ -4166,9 +4233,9 @@ function useConversationRealtime(conversationId, onEvent) {
|
|
|
4166
4233
|
}
|
|
4167
4234
|
function useGlobalRealtime(onEvent) {
|
|
4168
4235
|
const sse = useConversations()?.sse;
|
|
4169
|
-
const onEventRef =
|
|
4236
|
+
const onEventRef = useRef9(onEvent);
|
|
4170
4237
|
onEventRef.current = onEvent;
|
|
4171
|
-
|
|
4238
|
+
useEffect11(() => {
|
|
4172
4239
|
if (!sse) return;
|
|
4173
4240
|
const source = sse.connectGlobalStream();
|
|
4174
4241
|
const handler = (event) => onEventRef.current(event);
|
|
@@ -4181,14 +4248,14 @@ function useGlobalRealtime(onEvent) {
|
|
|
4181
4248
|
}
|
|
4182
4249
|
|
|
4183
4250
|
// src/hooks/useConversationActions.ts
|
|
4184
|
-
import { useMemo as
|
|
4251
|
+
import { useMemo as useMemo4 } from "react";
|
|
4185
4252
|
function useConversationActions(conversationId) {
|
|
4186
4253
|
const context = useConversations();
|
|
4187
4254
|
if (!context) {
|
|
4188
4255
|
throw new Error("useConversationActions requires an ancestor <ConversationsProvider>");
|
|
4189
4256
|
}
|
|
4190
4257
|
const { api } = context;
|
|
4191
|
-
return
|
|
4258
|
+
return useMemo4(
|
|
4192
4259
|
() => ({
|
|
4193
4260
|
takeover: api.takeover ? () => api.takeover(conversationId) : void 0,
|
|
4194
4261
|
release: api.release ? () => api.release(conversationId) : void 0,
|
|
@@ -4203,7 +4270,7 @@ function useInboxActions() {
|
|
|
4203
4270
|
throw new Error("useInboxActions requires an ancestor <ConversationsProvider>");
|
|
4204
4271
|
}
|
|
4205
4272
|
const { api } = context;
|
|
4206
|
-
return
|
|
4273
|
+
return useMemo4(
|
|
4207
4274
|
() => ({
|
|
4208
4275
|
markAllRead: api.markAllRead ? () => api.markAllRead() : void 0,
|
|
4209
4276
|
listTemplates: api.listTemplates ? () => api.listTemplates() : void 0
|
|
@@ -4347,11 +4414,11 @@ async function sendQueuedMessage(params) {
|
|
|
4347
4414
|
}
|
|
4348
4415
|
|
|
4349
4416
|
// src/quickReplies/QuickRepliesWorkspace.tsx
|
|
4350
|
-
import { useEffect as
|
|
4417
|
+
import { useEffect as useEffect14, useId as useId2, useRef as useRef12, useState as useState19 } from "react";
|
|
4351
4418
|
import { Pencil as Pencil2, Plus as Plus2, Trash2 as Trash23 } from "lucide-react";
|
|
4352
4419
|
|
|
4353
4420
|
// src/quickReplies/useQuickRepliesWorkspace.ts
|
|
4354
|
-
import { useCallback as useCallback10, useEffect as
|
|
4421
|
+
import { useCallback as useCallback10, useEffect as useEffect13, useState as useState18 } from "react";
|
|
4355
4422
|
|
|
4356
4423
|
// src/quickReplies/quickReplyAttachmentUpload.ts
|
|
4357
4424
|
function validateAttachmentFiles(files, currentCount, limits) {
|
|
@@ -4383,7 +4450,7 @@ function moveAttachment(list, index, direction) {
|
|
|
4383
4450
|
}
|
|
4384
4451
|
|
|
4385
4452
|
// src/quickReplies/useQuickReplyAttachmentUploads.ts
|
|
4386
|
-
import { useCallback as useCallback9, useEffect as
|
|
4453
|
+
import { useCallback as useCallback9, useEffect as useEffect12, useRef as useRef10, useState as useState17 } from "react";
|
|
4387
4454
|
|
|
4388
4455
|
// src/quickReplies/createUploadQueue.ts
|
|
4389
4456
|
function createUploadQueue(maxConcurrent) {
|
|
@@ -4439,11 +4506,11 @@ function useQuickReplyAttachmentUploads({
|
|
|
4439
4506
|
attachmentsCount,
|
|
4440
4507
|
onUploaded
|
|
4441
4508
|
}) {
|
|
4442
|
-
const [pendingUploads, setPendingUploads] =
|
|
4443
|
-
const [attachmentRejections, setAttachmentRejections] =
|
|
4444
|
-
const uploadQueueRef =
|
|
4445
|
-
const isMountedRef =
|
|
4446
|
-
|
|
4509
|
+
const [pendingUploads, setPendingUploads] = useState17([]);
|
|
4510
|
+
const [attachmentRejections, setAttachmentRejections] = useState17([]);
|
|
4511
|
+
const uploadQueueRef = useRef10(createUploadQueue(3));
|
|
4512
|
+
const isMountedRef = useRef10(true);
|
|
4513
|
+
useEffect12(
|
|
4447
4514
|
() => () => {
|
|
4448
4515
|
isMountedRef.current = false;
|
|
4449
4516
|
uploadQueueRef.current.abortAll();
|
|
@@ -4621,15 +4688,15 @@ function useQuickRepliesWorkspace({
|
|
|
4621
4688
|
labels,
|
|
4622
4689
|
attachmentSizeLimits
|
|
4623
4690
|
}) {
|
|
4624
|
-
const [quickReplies, setQuickReplies] =
|
|
4625
|
-
const [isLoading, setIsLoading] =
|
|
4626
|
-
const [loadError, setLoadError] =
|
|
4627
|
-
const [search, setSearch] =
|
|
4628
|
-
const [editing, setEditing] =
|
|
4629
|
-
const [fieldErrors, setFieldErrors] =
|
|
4630
|
-
const [isSaving, setIsSaving] =
|
|
4631
|
-
const [saveError, setSaveError] =
|
|
4632
|
-
const [deletingId, setDeletingId] =
|
|
4691
|
+
const [quickReplies, setQuickReplies] = useState18([]);
|
|
4692
|
+
const [isLoading, setIsLoading] = useState18(false);
|
|
4693
|
+
const [loadError, setLoadError] = useState18(void 0);
|
|
4694
|
+
const [search, setSearch] = useState18("");
|
|
4695
|
+
const [editing, setEditing] = useState18(void 0);
|
|
4696
|
+
const [fieldErrors, setFieldErrors] = useState18({});
|
|
4697
|
+
const [isSaving, setIsSaving] = useState18(false);
|
|
4698
|
+
const [saveError, setSaveError] = useState18(void 0);
|
|
4699
|
+
const [deletingId, setDeletingId] = useState18(void 0);
|
|
4633
4700
|
const uploadAttachment = useCallback10((attachment) => {
|
|
4634
4701
|
setEditing((current) => current ? { ...current, attachments: [...current.attachments, attachment] } : current);
|
|
4635
4702
|
}, []);
|
|
@@ -4648,7 +4715,7 @@ function useQuickRepliesWorkspace({
|
|
|
4648
4715
|
attachmentsCount: editing?.attachments.length ?? 0,
|
|
4649
4716
|
onUploaded: uploadAttachment
|
|
4650
4717
|
});
|
|
4651
|
-
|
|
4718
|
+
useEffect13(() => {
|
|
4652
4719
|
if (!api.listQuickReplies) return;
|
|
4653
4720
|
let cancelled = false;
|
|
4654
4721
|
setIsLoading(true);
|
|
@@ -4976,7 +5043,7 @@ function QuickReplyWhatsAppPreview({ body, variables, attachments, labels }) {
|
|
|
4976
5043
|
}
|
|
4977
5044
|
|
|
4978
5045
|
// src/quickReplies/AttachmentsFormSection.tsx
|
|
4979
|
-
import { useRef as
|
|
5046
|
+
import { useRef as useRef11 } from "react";
|
|
4980
5047
|
import { Paperclip as Paperclip3, X as X4 } from "lucide-react";
|
|
4981
5048
|
import { Fragment as Fragment7, jsx as jsx27, jsxs as jsxs24 } from "react/jsx-runtime";
|
|
4982
5049
|
function AttachmentsFormSection({
|
|
@@ -4991,7 +5058,7 @@ function AttachmentsFormSection({
|
|
|
4991
5058
|
onMoveAttachment,
|
|
4992
5059
|
onDismissRejections
|
|
4993
5060
|
}) {
|
|
4994
|
-
const attachmentFileInputRef =
|
|
5061
|
+
const attachmentFileInputRef = useRef11(null);
|
|
4995
5062
|
return /* @__PURE__ */ jsxs24("div", { className: "space-y-2", children: [
|
|
4996
5063
|
/* @__PURE__ */ jsx27("span", { className: "block text-sm font-medium", children: text.attachmentsTitle }),
|
|
4997
5064
|
attachments.length === 0 && pendingUploads.length === 0 ? /* @__PURE__ */ jsx27("p", { className: "text-xs text-gray-500 dark:text-gray-400", children: text.attachmentsEmpty }) : /* @__PURE__ */ jsxs24("ul", { className: "space-y-1", children: [
|
|
@@ -5126,15 +5193,15 @@ function QuickRepliesWorkspace({
|
|
|
5126
5193
|
attachmentSizeLimits
|
|
5127
5194
|
}) {
|
|
5128
5195
|
const text = { ...DEFAULT_QUICK_REPLIES_WORKSPACE_LABELS, ...labels };
|
|
5129
|
-
const bodyFieldRef =
|
|
5196
|
+
const bodyFieldRef = useRef12(null);
|
|
5130
5197
|
const formId = useId2();
|
|
5131
5198
|
const titleFieldId = `${formId}-title`;
|
|
5132
5199
|
const shortcutFieldId = `${formId}-shortcut`;
|
|
5133
5200
|
const bodyFieldId = `${formId}-body`;
|
|
5134
|
-
const [confirmingDeleteId, setConfirmingDeleteId] =
|
|
5135
|
-
const deleteButtonRefs =
|
|
5136
|
-
const confirmButtonRef =
|
|
5137
|
-
|
|
5201
|
+
const [confirmingDeleteId, setConfirmingDeleteId] = useState19(null);
|
|
5202
|
+
const deleteButtonRefs = useRef12({});
|
|
5203
|
+
const confirmButtonRef = useRef12(null);
|
|
5204
|
+
useEffect14(() => {
|
|
5138
5205
|
if (confirmingDeleteId) confirmButtonRef.current?.focus();
|
|
5139
5206
|
}, [confirmingDeleteId]);
|
|
5140
5207
|
const cancelDeleteConfirm = (id) => {
|
|
@@ -5469,11 +5536,11 @@ function QuickRepliesWorkspace({
|
|
|
5469
5536
|
}
|
|
5470
5537
|
|
|
5471
5538
|
// src/workspace/ConversationsWorkspace.tsx
|
|
5472
|
-
import { useEffect as
|
|
5539
|
+
import { useEffect as useEffect20, useMemo as useMemo7, useState as useState26 } from "react";
|
|
5473
5540
|
import { Check as Check2, CheckCheck, FlaskConical, Hourglass as Hourglass2, Mail as Mail2, MessagesSquare } from "lucide-react";
|
|
5474
5541
|
|
|
5475
5542
|
// src/workspace/BulkTemplateModal.tsx
|
|
5476
|
-
import { useEffect as
|
|
5543
|
+
import { useEffect as useEffect15, useMemo as useMemo5, useState as useState20 } from "react";
|
|
5477
5544
|
import { jsx as jsx29, jsxs as jsxs26 } from "react/jsx-runtime";
|
|
5478
5545
|
function BulkTemplateModal({
|
|
5479
5546
|
labels,
|
|
@@ -5487,10 +5554,10 @@ function BulkTemplateModal({
|
|
|
5487
5554
|
throw new Error("BulkTemplateModal requires an ancestor <ConversationsProvider>");
|
|
5488
5555
|
}
|
|
5489
5556
|
const { api } = context;
|
|
5490
|
-
const [templates, setTemplates] =
|
|
5491
|
-
const [search, setSearch] =
|
|
5492
|
-
const [selected, setSelected] =
|
|
5493
|
-
|
|
5557
|
+
const [templates, setTemplates] = useState20([]);
|
|
5558
|
+
const [search, setSearch] = useState20("");
|
|
5559
|
+
const [selected, setSelected] = useState20("");
|
|
5560
|
+
useEffect15(() => {
|
|
5494
5561
|
let active = true;
|
|
5495
5562
|
void api.listTemplates?.().then((loaded) => {
|
|
5496
5563
|
if (active) setTemplates(loaded);
|
|
@@ -5500,7 +5567,7 @@ function BulkTemplateModal({
|
|
|
5500
5567
|
active = false;
|
|
5501
5568
|
};
|
|
5502
5569
|
}, [api]);
|
|
5503
|
-
const filtered =
|
|
5570
|
+
const filtered = useMemo5(() => {
|
|
5504
5571
|
const term = search.trim().toLowerCase();
|
|
5505
5572
|
if (!term) return templates;
|
|
5506
5573
|
return templates.filter((template) => template.name.toLowerCase().includes(term));
|
|
@@ -5564,7 +5631,48 @@ function BulkTemplateModal({
|
|
|
5564
5631
|
}
|
|
5565
5632
|
|
|
5566
5633
|
// src/workspace/ConversationPane.tsx
|
|
5567
|
-
import { useCallback as
|
|
5634
|
+
import { useCallback as useCallback14, useEffect as useEffect18, useRef as useRef17, useState as useState24 } from "react";
|
|
5635
|
+
|
|
5636
|
+
// src/hooks/useLoadOlderMessagesScroll.ts
|
|
5637
|
+
import { useCallback as useCallback11, useLayoutEffect as useLayoutEffect2, useRef as useRef13 } from "react";
|
|
5638
|
+
var NEAR_TOP_THRESHOLD_PX = 200;
|
|
5639
|
+
function computeScrollTopAfterPrepend(before, scrollHeightAfter) {
|
|
5640
|
+
return before.scrollTop + (scrollHeightAfter - before.scrollHeight);
|
|
5641
|
+
}
|
|
5642
|
+
function useLoadOlderMessagesScroll({
|
|
5643
|
+
containerRef,
|
|
5644
|
+
oldestMessageId,
|
|
5645
|
+
hasMore,
|
|
5646
|
+
loading,
|
|
5647
|
+
onLoadOlder
|
|
5648
|
+
}) {
|
|
5649
|
+
const pendingSnapshotRef = useRef13(null);
|
|
5650
|
+
const previousOldestMessageIdRef = useRef13(oldestMessageId);
|
|
5651
|
+
const triggerLoadOlder = useCallback11(() => {
|
|
5652
|
+
if (loading || !hasMore) return;
|
|
5653
|
+
const container = containerRef.current;
|
|
5654
|
+
if (!container) return;
|
|
5655
|
+
pendingSnapshotRef.current = { scrollHeight: container.scrollHeight, scrollTop: container.scrollTop };
|
|
5656
|
+
onLoadOlder();
|
|
5657
|
+
}, [containerRef, hasMore, loading, onLoadOlder]);
|
|
5658
|
+
const handleScroll = useCallback11(
|
|
5659
|
+
(event) => {
|
|
5660
|
+
if (event.currentTarget.scrollTop > NEAR_TOP_THRESHOLD_PX) return;
|
|
5661
|
+
triggerLoadOlder();
|
|
5662
|
+
},
|
|
5663
|
+
[triggerLoadOlder]
|
|
5664
|
+
);
|
|
5665
|
+
useLayoutEffect2(() => {
|
|
5666
|
+
const idChanged = previousOldestMessageIdRef.current !== oldestMessageId;
|
|
5667
|
+
previousOldestMessageIdRef.current = oldestMessageId;
|
|
5668
|
+
const container = containerRef.current;
|
|
5669
|
+
const pending = pendingSnapshotRef.current;
|
|
5670
|
+
if (!idChanged || !container || !pending) return;
|
|
5671
|
+
pendingSnapshotRef.current = null;
|
|
5672
|
+
container.scrollTop = computeScrollTopAfterPrepend(pending, container.scrollHeight);
|
|
5673
|
+
}, [oldestMessageId, containerRef]);
|
|
5674
|
+
return { handleScroll, triggerLoadOlder };
|
|
5675
|
+
}
|
|
5568
5676
|
|
|
5569
5677
|
// src/quickReplies/resolveConversationVariables.ts
|
|
5570
5678
|
var MARKER_PATTERN = /^\{\{\s*([\w.]+)\s*\}\}$/;
|
|
@@ -5585,7 +5693,7 @@ function resolveConversationVariables({
|
|
|
5585
5693
|
}
|
|
5586
5694
|
|
|
5587
5695
|
// src/workspace/QueuedAttachmentsList.tsx
|
|
5588
|
-
import { useEffect as
|
|
5696
|
+
import { useEffect as useEffect16, useRef as useRef14, useState as useState21 } from "react";
|
|
5589
5697
|
import { jsx as jsx30, jsxs as jsxs27 } from "react/jsx-runtime";
|
|
5590
5698
|
function nameOf(item) {
|
|
5591
5699
|
return item.kind === "local" ? item.file.name : item.filename;
|
|
@@ -5601,16 +5709,16 @@ function AttachmentThumbnail({
|
|
|
5601
5709
|
getThumbnailUrl
|
|
5602
5710
|
}) {
|
|
5603
5711
|
const isImage = mimeTypeOf(item).startsWith("image/");
|
|
5604
|
-
const [url, setUrl] =
|
|
5712
|
+
const [url, setUrl] = useState21(item.kind === "local" ? void 0 : item.previewUrl);
|
|
5605
5713
|
const localFile = item.kind === "local" ? item.file : void 0;
|
|
5606
5714
|
const uploadId = item.kind === "stored" ? item.uploadId : void 0;
|
|
5607
|
-
|
|
5715
|
+
useEffect16(() => {
|
|
5608
5716
|
if (!isImage || !localFile) return;
|
|
5609
5717
|
const objectUrl = URL.createObjectURL(localFile);
|
|
5610
5718
|
setUrl(objectUrl);
|
|
5611
5719
|
return () => URL.revokeObjectURL(objectUrl);
|
|
5612
5720
|
}, [isImage, localFile]);
|
|
5613
|
-
|
|
5721
|
+
useEffect16(() => {
|
|
5614
5722
|
if (!isImage || !uploadId || !getThumbnailUrl) return;
|
|
5615
5723
|
let cancelled = false;
|
|
5616
5724
|
getThumbnailUrl(uploadId).then((resolved) => {
|
|
@@ -5628,11 +5736,11 @@ function departedItemsOf(previouslyRendered, items) {
|
|
|
5628
5736
|
return previouslyRendered.filter((item) => !currentKeys.has(attachmentKey(item)));
|
|
5629
5737
|
}
|
|
5630
5738
|
function useDepartingItems(items) {
|
|
5631
|
-
const [rendered, setRendered] =
|
|
5632
|
-
const [departingKeys, setDepartingKeys] =
|
|
5633
|
-
const renderedRef =
|
|
5739
|
+
const [rendered, setRendered] = useState21(items);
|
|
5740
|
+
const [departingKeys, setDepartingKeys] = useState21(/* @__PURE__ */ new Set());
|
|
5741
|
+
const renderedRef = useRef14(rendered);
|
|
5634
5742
|
renderedRef.current = rendered;
|
|
5635
|
-
|
|
5743
|
+
useEffect16(() => {
|
|
5636
5744
|
const removedItems = departedItemsOf(renderedRef.current, items);
|
|
5637
5745
|
if (removedItems.length === 0) {
|
|
5638
5746
|
setRendered(items);
|
|
@@ -5716,10 +5824,10 @@ function QueuedAttachmentsList({
|
|
|
5716
5824
|
}
|
|
5717
5825
|
|
|
5718
5826
|
// src/workspace/useComposerQueue.ts
|
|
5719
|
-
import { useCallback as
|
|
5827
|
+
import { useCallback as useCallback13, useEffect as useEffect17, useRef as useRef16, useState as useState23 } from "react";
|
|
5720
5828
|
|
|
5721
5829
|
// src/workspace/useComposerAttachmentRetry.ts
|
|
5722
|
-
import { useCallback as
|
|
5830
|
+
import { useCallback as useCallback12, useRef as useRef15, useState as useState22 } from "react";
|
|
5723
5831
|
function useComposerAttachmentRetry(params) {
|
|
5724
5832
|
const {
|
|
5725
5833
|
conversationId,
|
|
@@ -5733,9 +5841,9 @@ function useComposerAttachmentRetry(params) {
|
|
|
5733
5841
|
labels,
|
|
5734
5842
|
sendInFlightRef
|
|
5735
5843
|
} = params;
|
|
5736
|
-
const retryIdempotencyKeyRef =
|
|
5737
|
-
const retryingKeysRef =
|
|
5738
|
-
const [retryingKeys, setRetryingKeys] =
|
|
5844
|
+
const retryIdempotencyKeyRef = useRef15(void 0);
|
|
5845
|
+
const retryingKeysRef = useRef15(/* @__PURE__ */ new Set());
|
|
5846
|
+
const [retryingKeys, setRetryingKeys] = useState22(/* @__PURE__ */ new Set());
|
|
5739
5847
|
function markRetrying(key) {
|
|
5740
5848
|
retryingKeysRef.current.add(key);
|
|
5741
5849
|
setRetryingKeys(new Set(retryingKeysRef.current));
|
|
@@ -5811,7 +5919,7 @@ function useComposerAttachmentRetry(params) {
|
|
|
5811
5919
|
}
|
|
5812
5920
|
void retryStoredAttachment(item).finally(release);
|
|
5813
5921
|
}
|
|
5814
|
-
const resetRetryState =
|
|
5922
|
+
const resetRetryState = useCallback12(() => {
|
|
5815
5923
|
retryIdempotencyKeyRef.current = void 0;
|
|
5816
5924
|
retryingKeysRef.current.clear();
|
|
5817
5925
|
setRetryingKeys(/* @__PURE__ */ new Set());
|
|
@@ -5822,12 +5930,12 @@ function useComposerAttachmentRetry(params) {
|
|
|
5822
5930
|
// src/workspace/useComposerQueue.ts
|
|
5823
5931
|
function useComposerQueue(params) {
|
|
5824
5932
|
const { conversationId, draft, setDraft, api, labels, onSendAttachments, refetch, setSendFailure } = params;
|
|
5825
|
-
const [queue, setQueue] =
|
|
5826
|
-
const [attachmentStatus, setAttachmentStatus] =
|
|
5827
|
-
const [isSendingDraft, setIsSendingDraft] =
|
|
5828
|
-
const sendInFlightRef =
|
|
5829
|
-
const idempotencyKeyRef =
|
|
5830
|
-
const currentConversationIdRef =
|
|
5933
|
+
const [queue, setQueue] = useState23([]);
|
|
5934
|
+
const [attachmentStatus, setAttachmentStatus] = useState23({});
|
|
5935
|
+
const [isSendingDraft, setIsSendingDraft] = useState23(false);
|
|
5936
|
+
const sendInFlightRef = useRef16(false);
|
|
5937
|
+
const idempotencyKeyRef = useRef16(void 0);
|
|
5938
|
+
const currentConversationIdRef = useRef16(conversationId);
|
|
5831
5939
|
const { retryQueuedAttachment, resetRetryState, retryingKeys } = useComposerAttachmentRetry({
|
|
5832
5940
|
conversationId,
|
|
5833
5941
|
currentConversationIdRef,
|
|
@@ -5840,7 +5948,7 @@ function useComposerQueue(params) {
|
|
|
5840
5948
|
sendInFlightRef,
|
|
5841
5949
|
...onSendAttachments ? { onSendAttachments } : {}
|
|
5842
5950
|
});
|
|
5843
|
-
|
|
5951
|
+
useEffect17(() => {
|
|
5844
5952
|
currentConversationIdRef.current = conversationId;
|
|
5845
5953
|
setQueue([]);
|
|
5846
5954
|
setAttachmentStatus({});
|
|
@@ -5849,11 +5957,11 @@ function useComposerQueue(params) {
|
|
|
5849
5957
|
setIsSendingDraft(false);
|
|
5850
5958
|
resetRetryState();
|
|
5851
5959
|
}, [conversationId, resetRetryState]);
|
|
5852
|
-
const enqueueAttachments =
|
|
5960
|
+
const enqueueAttachments = useCallback13((items) => {
|
|
5853
5961
|
if (items.length === 0) return;
|
|
5854
5962
|
setQueue((current) => [...current, ...items]);
|
|
5855
5963
|
}, []);
|
|
5856
|
-
const removeQueuedAttachment =
|
|
5964
|
+
const removeQueuedAttachment = useCallback13((item) => {
|
|
5857
5965
|
setQueue((current) => current.filter((queued) => attachmentKey(queued) !== attachmentKey(item)));
|
|
5858
5966
|
}, []);
|
|
5859
5967
|
async function runSend(action, fallback) {
|
|
@@ -5991,12 +6099,14 @@ function ConversationPane({
|
|
|
5991
6099
|
throw new Error("ConversationPane requires an ancestor <ConversationsProvider>");
|
|
5992
6100
|
}
|
|
5993
6101
|
const { api } = context;
|
|
5994
|
-
const { messages, refetch } = useConversationMessages(
|
|
6102
|
+
const { messages, refetch, loadOlderMessages, loadingOlderMessages, hasMoreOlderMessages } = useConversationMessages(
|
|
6103
|
+
conversation.id
|
|
6104
|
+
);
|
|
5995
6105
|
const { context: conversationContext } = useConversationContext(conversation.id);
|
|
5996
|
-
const [documentsOpen, setDocumentsOpen] =
|
|
5997
|
-
const [sendFailure, setSendFailure] =
|
|
5998
|
-
const [selectedMessageIds, setSelectedMessageIds] =
|
|
5999
|
-
const [draft, setDraft] =
|
|
6106
|
+
const [documentsOpen, setDocumentsOpen] = useState24(false);
|
|
6107
|
+
const [sendFailure, setSendFailure] = useState24(void 0);
|
|
6108
|
+
const [selectedMessageIds, setSelectedMessageIds] = useState24(/* @__PURE__ */ new Set());
|
|
6109
|
+
const [draft, setDraft] = useState24(initialComposerText ?? "");
|
|
6000
6110
|
const {
|
|
6001
6111
|
queue,
|
|
6002
6112
|
enqueueAttachments,
|
|
@@ -6016,8 +6126,8 @@ function ConversationPane({
|
|
|
6016
6126
|
refetch,
|
|
6017
6127
|
setSendFailure
|
|
6018
6128
|
});
|
|
6019
|
-
const thumbnailUrlCacheRef =
|
|
6020
|
-
const getQueuedAttachmentThumbnailUrl =
|
|
6129
|
+
const thumbnailUrlCacheRef = useRef17(/* @__PURE__ */ new Map());
|
|
6130
|
+
const getQueuedAttachmentThumbnailUrl = useCallback14(
|
|
6021
6131
|
(uploadId) => {
|
|
6022
6132
|
const cached = thumbnailUrlCacheRef.current.get(uploadId);
|
|
6023
6133
|
if (cached) return cached;
|
|
@@ -6028,7 +6138,7 @@ function ConversationPane({
|
|
|
6028
6138
|
},
|
|
6029
6139
|
[api]
|
|
6030
6140
|
);
|
|
6031
|
-
|
|
6141
|
+
useEffect18(() => {
|
|
6032
6142
|
setSelectedMessageIds(/* @__PURE__ */ new Set());
|
|
6033
6143
|
setDraft(initialComposerText ?? "");
|
|
6034
6144
|
}, [conversation.id, initialComposerText]);
|
|
@@ -6048,6 +6158,13 @@ function ConversationPane({
|
|
|
6048
6158
|
setSelectedMessageIds(/* @__PURE__ */ new Set());
|
|
6049
6159
|
}
|
|
6050
6160
|
const scroll = useScrollToLatestMessage({ conversationId: conversation.id, messageCount: messages.length });
|
|
6161
|
+
const loadOlder = useLoadOlderMessagesScroll({
|
|
6162
|
+
containerRef: scroll.containerRef,
|
|
6163
|
+
oldestMessageId: messages[0]?.id,
|
|
6164
|
+
hasMore: hasMoreOlderMessages,
|
|
6165
|
+
loading: loadingOlderMessages,
|
|
6166
|
+
onLoadOlder: () => void loadOlderMessages()
|
|
6167
|
+
});
|
|
6051
6168
|
useConversationRealtime(conversation.id, () => {
|
|
6052
6169
|
void refetch();
|
|
6053
6170
|
});
|
|
@@ -6131,32 +6248,38 @@ function ConversationPane({
|
|
|
6131
6248
|
contextEntries && contextEntries.length > 0 || flowLabel ? /* @__PURE__ */ jsx31(ConversationContextPanel, { entries: contextEntries ?? [], ...flowLabel ? { flowLabel } : {} }) : null,
|
|
6132
6249
|
/* @__PURE__ */ jsx31(ConversationDocumentsPanel, { conversationId: conversation.id, open: documentsOpen }),
|
|
6133
6250
|
renderAboveTranscript?.(conversation, conversationContext),
|
|
6134
|
-
/* @__PURE__ */
|
|
6251
|
+
/* @__PURE__ */ jsxs28(
|
|
6135
6252
|
ConversationWallpaper,
|
|
6136
6253
|
{
|
|
6137
6254
|
ref: scroll.containerRef,
|
|
6138
|
-
onScroll:
|
|
6255
|
+
onScroll: (event) => {
|
|
6256
|
+
scroll.handleScroll(event);
|
|
6257
|
+
loadOlder.handleScroll(event);
|
|
6258
|
+
},
|
|
6139
6259
|
className: "cv-workspace-transcript",
|
|
6140
|
-
children:
|
|
6141
|
-
|
|
6142
|
-
|
|
6143
|
-
|
|
6144
|
-
startsNewDay
|
|
6145
|
-
/* @__PURE__ */
|
|
6146
|
-
|
|
6147
|
-
|
|
6148
|
-
|
|
6149
|
-
|
|
6150
|
-
|
|
6151
|
-
|
|
6152
|
-
|
|
6153
|
-
|
|
6154
|
-
|
|
6155
|
-
|
|
6156
|
-
|
|
6157
|
-
|
|
6158
|
-
|
|
6159
|
-
|
|
6260
|
+
children: [
|
|
6261
|
+
hasMoreOlderMessages ? /* @__PURE__ */ jsx31("div", { className: "cv-workspace-load-older", children: /* @__PURE__ */ jsx31("button", { type: "button", onClick: loadOlder.triggerLoadOlder, disabled: loadingOlderMessages, children: loadingOlderMessages ? labels.loadingOlderMessages : labels.loadOlderMessages }) }) : null,
|
|
6262
|
+
messages.map((message, index) => {
|
|
6263
|
+
const previous = index > 0 ? messages[index - 1] : void 0;
|
|
6264
|
+
const startsNewDay = !previous || new Date(message.timestamp).toDateString() !== new Date(previous.timestamp).toDateString();
|
|
6265
|
+
return /* @__PURE__ */ jsxs28("div", { children: [
|
|
6266
|
+
startsNewDay ? /* @__PURE__ */ jsx31(DateDivider, { iso: message.timestamp }) : null,
|
|
6267
|
+
/* @__PURE__ */ jsx31(
|
|
6268
|
+
MessageBubble,
|
|
6269
|
+
{
|
|
6270
|
+
message,
|
|
6271
|
+
isMine: message.direction === "outbound",
|
|
6272
|
+
isFirstInGroup: !previous || previous.sender !== message.sender,
|
|
6273
|
+
...messageSelection ? {
|
|
6274
|
+
isSelecting: selectedMessageIds.size > 0,
|
|
6275
|
+
isSelected: selectedMessageIds.has(message.id),
|
|
6276
|
+
onToggleSelect: () => toggleMessageSelected(message.id)
|
|
6277
|
+
} : {}
|
|
6278
|
+
}
|
|
6279
|
+
)
|
|
6280
|
+
] }, message.id);
|
|
6281
|
+
})
|
|
6282
|
+
]
|
|
6160
6283
|
}
|
|
6161
6284
|
),
|
|
6162
6285
|
messageSelection && selectedMessageIds.size > 0 ? /* @__PURE__ */ jsxs28("div", { className: "cv-workspace-selection", children: [
|
|
@@ -6196,9 +6319,11 @@ function ConversationPane({
|
|
|
6196
6319
|
onSend: () => void handleRichSend(),
|
|
6197
6320
|
...onSendAttachments ? {
|
|
6198
6321
|
onAttachFiles: (files) => enqueueAttachments(
|
|
6199
|
-
Array.from(files).map(
|
|
6200
|
-
|
|
6201
|
-
|
|
6322
|
+
Array.from(files).map((file) => ({
|
|
6323
|
+
kind: "local",
|
|
6324
|
+
localId: crypto.randomUUID(),
|
|
6325
|
+
file
|
|
6326
|
+
}))
|
|
6202
6327
|
)
|
|
6203
6328
|
} : onAttach ? {
|
|
6204
6329
|
onAttachFiles: (files) => {
|
|
@@ -6444,6 +6569,8 @@ var DEFAULT_CONVERSATIONS_WORKSPACE_LABELS = {
|
|
|
6444
6569
|
templateModalSend: (count) => `Enviar para ${count}`,
|
|
6445
6570
|
messagesSelected: (count) => `${count} mensagem${count === 1 ? "" : "s"} selecionada${count === 1 ? "" : "s"}`,
|
|
6446
6571
|
copySelected: "Copiar",
|
|
6572
|
+
loadOlderMessages: "Carregar mensagens anteriores",
|
|
6573
|
+
loadingOlderMessages: "Carregando\u2026",
|
|
6447
6574
|
composerPlaceholder: "Responder como atendente\u2026",
|
|
6448
6575
|
attachFailure: "Falha ao enviar o arquivo.",
|
|
6449
6576
|
attachmentRemove: "Remover anexo",
|
|
@@ -6462,7 +6589,7 @@ var DEFAULT_CONVERSATIONS_WORKSPACE_LABELS = {
|
|
|
6462
6589
|
};
|
|
6463
6590
|
|
|
6464
6591
|
// src/workspace/useConversationsInbox.ts
|
|
6465
|
-
import { useCallback as
|
|
6592
|
+
import { useCallback as useCallback15, useEffect as useEffect19, useMemo as useMemo6, useRef as useRef18, useState as useState25 } from "react";
|
|
6466
6593
|
var CONVERSATIONS_PER_PAGE = 50;
|
|
6467
6594
|
function defaultDescribeFailure(error) {
|
|
6468
6595
|
const status = error.status;
|
|
@@ -6480,14 +6607,14 @@ function useConversationsInbox(params = {}) {
|
|
|
6480
6607
|
const perPage = params.perPage ?? CONVERSATIONS_PER_PAGE;
|
|
6481
6608
|
const markReadOnOpen = params.markReadOnOpen ?? true;
|
|
6482
6609
|
const describeFailure = params.describeFailure ?? defaultDescribeFailure;
|
|
6483
|
-
const [waitingOnly, setWaitingOnly] =
|
|
6484
|
-
const [windowFilter, setWindowFilter] =
|
|
6485
|
-
const [channelFilter, setChannelFilter] =
|
|
6486
|
-
const [search, setSearch] =
|
|
6487
|
-
const [page, setPage] =
|
|
6488
|
-
const [selectedId, setSelectedId] =
|
|
6489
|
-
const [selectedIds, setSelectedIds] =
|
|
6490
|
-
const [busy, setBusy] =
|
|
6610
|
+
const [waitingOnly, setWaitingOnly] = useState25(false);
|
|
6611
|
+
const [windowFilter, setWindowFilter] = useState25(CONVERSATION_WINDOW.ALL);
|
|
6612
|
+
const [channelFilter, setChannelFilter] = useState25(CHANNEL_FILTER_ALL);
|
|
6613
|
+
const [search, setSearch] = useState25("");
|
|
6614
|
+
const [page, setPage] = useState25(1);
|
|
6615
|
+
const [selectedId, setSelectedId] = useState25(void 0);
|
|
6616
|
+
const [selectedIds, setSelectedIds] = useState25(/* @__PURE__ */ new Set());
|
|
6617
|
+
const [busy, setBusy] = useState25(false);
|
|
6491
6618
|
const { conversations, total, loading, error, refetch } = useConversationList({
|
|
6492
6619
|
...waitingOnly ? { waitingHuman: true } : {},
|
|
6493
6620
|
...search ? { search } : {},
|
|
@@ -6495,12 +6622,12 @@ function useConversationsInbox(params = {}) {
|
|
|
6495
6622
|
...params.serverPaginated ? { page, limit: perPage } : {}
|
|
6496
6623
|
});
|
|
6497
6624
|
useGlobalRealtime(
|
|
6498
|
-
|
|
6625
|
+
useCallback15(() => {
|
|
6499
6626
|
void refetch();
|
|
6500
6627
|
}, [refetch])
|
|
6501
6628
|
);
|
|
6502
|
-
const now =
|
|
6503
|
-
const filtered =
|
|
6629
|
+
const now = useMemo6(() => Date.now(), [conversations]);
|
|
6630
|
+
const filtered = useMemo6(
|
|
6504
6631
|
() => conversations.filter(
|
|
6505
6632
|
(conversation) => channelFilter === CHANNEL_FILTER_ALL || (conversation.channel ?? DEFAULT_CONVERSATION_CHANNEL) === channelFilter
|
|
6506
6633
|
).filter(
|
|
@@ -6512,16 +6639,16 @@ function useConversationsInbox(params = {}) {
|
|
|
6512
6639
|
const pageCount = Math.max(1, Math.ceil(totalForPaging / perPage));
|
|
6513
6640
|
const currentPage = Math.min(page, pageCount);
|
|
6514
6641
|
const pageConversations = params.serverPaginated ? filtered : filtered.slice((currentPage - 1) * perPage, currentPage * perPage);
|
|
6515
|
-
|
|
6642
|
+
useEffect19(() => {
|
|
6516
6643
|
setPage(1);
|
|
6517
6644
|
}, [search, waitingOnly, windowFilter, channelFilter]);
|
|
6518
|
-
|
|
6645
|
+
useEffect19(() => {
|
|
6519
6646
|
if (selectedId && !conversations.some((conversation) => conversation.id === selectedId)) {
|
|
6520
6647
|
setSelectedId(void 0);
|
|
6521
6648
|
}
|
|
6522
6649
|
}, [conversations, selectedId]);
|
|
6523
|
-
const lastMarkReadAttempt =
|
|
6524
|
-
|
|
6650
|
+
const lastMarkReadAttempt = useRef18(void 0);
|
|
6651
|
+
useEffect19(() => {
|
|
6525
6652
|
if (!markReadOnOpen || !selectedId) return;
|
|
6526
6653
|
const opened = conversations.find((conversation) => conversation.id === selectedId);
|
|
6527
6654
|
if (!opened || opened.unread === 0) return;
|
|
@@ -6531,7 +6658,7 @@ function useConversationsInbox(params = {}) {
|
|
|
6531
6658
|
void api.markRead(selectedId).then(() => refetch()).catch(() => {
|
|
6532
6659
|
});
|
|
6533
6660
|
}, [markReadOnOpen, selectedId, conversations, refetch, api]);
|
|
6534
|
-
const toggleSelected =
|
|
6661
|
+
const toggleSelected = useCallback15((conversationId) => {
|
|
6535
6662
|
setSelectedIds((current) => {
|
|
6536
6663
|
const next = new Set(current);
|
|
6537
6664
|
if (next.has(conversationId)) next.delete(conversationId);
|
|
@@ -6541,7 +6668,7 @@ function useConversationsInbox(params = {}) {
|
|
|
6541
6668
|
}, []);
|
|
6542
6669
|
const pageIds = pageConversations.map((conversation) => conversation.id);
|
|
6543
6670
|
const allOnPageSelected = pageIds.length > 0 && pageIds.every((id) => selectedIds.has(id));
|
|
6544
|
-
const toggleSelectAllOnPage =
|
|
6671
|
+
const toggleSelectAllOnPage = useCallback15(() => {
|
|
6545
6672
|
setSelectedIds((current) => {
|
|
6546
6673
|
const next = new Set(current);
|
|
6547
6674
|
if (allOnPageSelected) pageIds.forEach((id) => next.delete(id));
|
|
@@ -6549,7 +6676,7 @@ function useConversationsInbox(params = {}) {
|
|
|
6549
6676
|
return next;
|
|
6550
6677
|
});
|
|
6551
6678
|
}, [allOnPageSelected, pageIds]);
|
|
6552
|
-
const runOnSelection =
|
|
6679
|
+
const runOnSelection = useCallback15(
|
|
6553
6680
|
async (action) => {
|
|
6554
6681
|
setBusy(true);
|
|
6555
6682
|
try {
|
|
@@ -6562,7 +6689,7 @@ function useConversationsInbox(params = {}) {
|
|
|
6562
6689
|
},
|
|
6563
6690
|
[selectedIds, refetch]
|
|
6564
6691
|
);
|
|
6565
|
-
const runConversationAction =
|
|
6692
|
+
const runConversationAction = useCallback15(
|
|
6566
6693
|
async (action, conversationId) => {
|
|
6567
6694
|
if (!action) return;
|
|
6568
6695
|
setBusy(true);
|
|
@@ -6685,10 +6812,10 @@ function ConversationsWorkspace({
|
|
|
6685
6812
|
...markReadOnOpen === void 0 ? {} : { markReadOnOpen },
|
|
6686
6813
|
...serverPaginated ? { serverPaginated } : {}
|
|
6687
6814
|
});
|
|
6688
|
-
const [simulatorOpen, setSimulatorOpen] =
|
|
6689
|
-
const [templateModalOpen, setTemplateModalOpen] =
|
|
6690
|
-
const [openedFromLink, setOpenedFromLink] =
|
|
6691
|
-
|
|
6815
|
+
const [simulatorOpen, setSimulatorOpen] = useState26(false);
|
|
6816
|
+
const [templateModalOpen, setTemplateModalOpen] = useState26(false);
|
|
6817
|
+
const [openedFromLink, setOpenedFromLink] = useState26(void 0);
|
|
6818
|
+
useEffect20(() => {
|
|
6692
6819
|
const link = initialConversationId ?? initialWhatsappNumber;
|
|
6693
6820
|
if (!link || openedFromLink === link) return;
|
|
6694
6821
|
const target = inbox.conversations.find(
|
|
@@ -6708,11 +6835,11 @@ function ConversationsWorkspace({
|
|
|
6708
6835
|
simulator && (simulator.enabled ?? true) && (simulator.render ?? transport)
|
|
6709
6836
|
);
|
|
6710
6837
|
const showSimulator = simulatorEnabled && simulatorOpen && Boolean(selected);
|
|
6711
|
-
const simulatorClient =
|
|
6838
|
+
const simulatorClient = useMemo7(
|
|
6712
6839
|
() => transport && selectedId ? transport({ conversationId: selectedId, channel, handle }) : void 0,
|
|
6713
6840
|
[transport, selectedId, channel, handle]
|
|
6714
6841
|
);
|
|
6715
|
-
const paneUtilities =
|
|
6842
|
+
const paneUtilities = useMemo7(() => {
|
|
6716
6843
|
if (!selected) return void 0;
|
|
6717
6844
|
const fromProduct = extraUtilitiesFor?.(selected) ?? [];
|
|
6718
6845
|
if (!simulatorEnabled) return fromProduct;
|
package/dist/preview/index.js
CHANGED
|
@@ -705,7 +705,9 @@ function createMockConversationsApi(params) {
|
|
|
705
705
|
},
|
|
706
706
|
fetchMessages(conversationId, fetchParams) {
|
|
707
707
|
return withLatency(() => {
|
|
708
|
-
const
|
|
708
|
+
const allMessages = params.store.listMessages(conversationId);
|
|
709
|
+
const before = fetchParams?.before;
|
|
710
|
+
const messages = before ? allMessages.filter((message) => message.timestamp < before) : allMessages;
|
|
709
711
|
const limit = fetchParams?.limit;
|
|
710
712
|
return limit ? messages.slice(-limit) : messages;
|
|
711
713
|
});
|
package/dist/styles.css
CHANGED
|
@@ -526,6 +526,22 @@
|
|
|
526
526
|
font-size: 0.875rem;
|
|
527
527
|
color: #6b7280;
|
|
528
528
|
}
|
|
529
|
+
.cv-workspace-load-older {
|
|
530
|
+
display: flex;
|
|
531
|
+
justify-content: center;
|
|
532
|
+
padding: 0.5rem 0;
|
|
533
|
+
}
|
|
534
|
+
.cv-workspace-load-older button {
|
|
535
|
+
min-height: 2.75rem;
|
|
536
|
+
border-radius: 0.375rem;
|
|
537
|
+
border: 1px solid var(--cv-border, #e5e7eb);
|
|
538
|
+
padding: 0 0.75rem;
|
|
539
|
+
font-size: 0.8125rem;
|
|
540
|
+
color: #6b7280;
|
|
541
|
+
}
|
|
542
|
+
.cv-workspace-load-older button:disabled {
|
|
543
|
+
opacity: 0.6;
|
|
544
|
+
}
|
|
529
545
|
.dark .cv-workspace-notice {
|
|
530
546
|
border-top-color: #374151;
|
|
531
547
|
background: #1f2937;
|
package/package.json
CHANGED
|
@@ -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
|
+
}
|
|
@@ -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 =
|
|
@@ -424,9 +447,11 @@ export function ConversationPane({
|
|
|
424
447
|
? {
|
|
425
448
|
onAttachFiles: (files: FileList) =>
|
|
426
449
|
enqueueAttachments(
|
|
427
|
-
Array.from(files).map(
|
|
428
|
-
|
|
429
|
-
|
|
450
|
+
Array.from(files).map((file): QueuedAttachment => ({
|
|
451
|
+
kind: 'local',
|
|
452
|
+
localId: crypto.randomUUID(),
|
|
453
|
+
file,
|
|
454
|
+
})),
|
|
430
455
|
),
|
|
431
456
|
}
|
|
432
457
|
: onAttach
|
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',
|