@pubuduth-aplicy/chat-ui 2.1.91 → 2.1.93

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pubuduth-aplicy/chat-ui",
3
- "version": "2.1.91",
3
+ "version": "2.1.93",
4
4
  "description": "This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.",
5
5
  "license": "ISC",
6
6
  "author": "",
@@ -23,33 +23,50 @@ const VirtualizedChatList = ({ conversations }: Props) => {
23
23
  <div
24
24
  ref={parentRef}
25
25
  style={{
26
- height: `${virtualizer.getTotalSize()}px`,
26
+ height: `400px`,
27
27
  width: "100%",
28
- position: "relative",
28
+ // position: "relative",
29
29
  overflow: "auto",
30
30
  }}
31
31
  >
32
- {virtualizer.getVirtualItems().map((item) => {
32
+ {/* {virtualizer.getVirtualItems().map((item) => {
33
33
  const conversation = conversations[item.index];
34
- return (
35
- <div
36
- key={item.key}
37
- style={{
38
- position: "absolute",
39
- top: 0,
40
- left: 0,
41
- width: "100%",
42
- height: `${item.size}px`,
43
- transform: `translateY(${item.start}px)`,
44
- }}
45
- >
46
- <Conversation
47
- conversation={conversation}
48
- lastIdx={item.index === conversations.length - 1}
49
- />
50
- </div>
51
- );
52
- })}
34
+ return ( */}
35
+ <div
36
+ // key={item.key}
37
+ style={{
38
+ position: "relative",
39
+ top: 0,
40
+ left: 0,
41
+ width: "100%",
42
+ height: `${virtualizer.getTotalSize()}px`,
43
+ // transform: `translateY(${item.start}px)`,
44
+ }}
45
+ >
46
+ {virtualizer.getVirtualItems().map((item) => {
47
+ const conversation = conversations[item.index];
48
+ return (
49
+ <div
50
+ key={item.key}
51
+ style={{
52
+ position: "absolute",
53
+ top: 0,
54
+ left: 0,
55
+ width: "100%",
56
+ height: `${item.size}px`,
57
+ transform: `translateY(${item.start}px)`,
58
+ }}
59
+ >
60
+ <Conversation
61
+ conversation={conversation}
62
+ lastIdx={item.index === conversations.length - 1}
63
+ />
64
+ </div>
65
+ );
66
+ })}
67
+ </div>
68
+ {/* ); */}
69
+ {/* })} */}
53
70
  </div>
54
71
  );
55
72
  };
@@ -51,31 +51,31 @@ interface MessageProps {
51
51
 
52
52
  const Message = ({ message }: MessageProps) => {
53
53
  const { userId } = useChatContext();
54
- const { apiUrl,cdnUrl ,role: role1} = getChatConfig();
54
+ const { apiUrl, cdnUrl, role: role1 } = getChatConfig();
55
55
 
56
56
  if (message.type === "system") {
57
57
  const booking = message.meta?.bookingDetails;
58
58
  const status = booking?.status || "Pending"; // Default to Pending
59
59
  const role = role1; // "provider" or "customer"
60
-
60
+
61
61
  const handleConfirm = () => {
62
62
  console.log("Booking confirmed!");
63
63
  window.location.href = `/booking/all`;
64
64
  // Update booking status to Confirmed
65
65
  };
66
-
66
+
67
67
  const handleInProgress = () => {
68
68
  console.log("Booking started (In Progress)");
69
69
  window.location.href = `/booking/all`;
70
70
  // Update booking status to InProgress
71
71
  };
72
-
72
+
73
73
  const handleComplete = () => {
74
74
  console.log("Booking completed!");
75
75
  window.location.href = `/booking/all`;
76
76
  // Update booking status to Completed
77
77
  };
78
-
78
+
79
79
  const handleReview = () => {
80
80
  if (role === "customer") {
81
81
  console.log("Navigate to review page for customer → provider");
@@ -85,35 +85,35 @@ const Message = ({ message }: MessageProps) => {
85
85
  window.location.href = `/booking/all`;
86
86
  }
87
87
  };
88
-
88
+
89
89
  return (
90
90
  <div className="system-message booking-details">
91
91
  <h4>{status}</h4>
92
92
  <div className="details">
93
93
  <p>Service: {booking?.serviceId}</p>
94
- <p>Date: {booking?.date.split('T')[0]}</p>
94
+ <p>Date: {booking?.date.split("T")[0]}</p>
95
95
  <p>Time: {booking?.time}</p>
96
96
  </div>
97
-
97
+
98
98
  {/* Actions based on role + status */}
99
99
  {role === "provider" && status === "Pending" && (
100
100
  <button className="confirm-button" onClick={handleConfirm}>
101
101
  Confirm Booking ✅
102
102
  </button>
103
103
  )}
104
-
104
+
105
105
  {role === "provider" && status === "Confirmed" && (
106
106
  <button className="confirm-button" onClick={handleInProgress}>
107
107
  Start Service ▶️
108
108
  </button>
109
109
  )}
110
-
110
+
111
111
  {role === "provider" && status === "InProgress" && (
112
112
  <button className="confirm-button" onClick={handleComplete}>
113
113
  Mark as Completed ✔️
114
114
  </button>
115
115
  )}
116
-
116
+
117
117
  {status === "Completed" && (
118
118
  <button className="confirm-button" onClick={handleReview}>
119
119
  Leave a Review ✍️
@@ -253,7 +253,6 @@ const Message = ({ message }: MessageProps) => {
253
253
  }
254
254
  };
255
255
 
256
-
257
256
  const handleDownload = async (url: string, name: string, index: number) => {
258
257
  setDownloadingIndex(index);
259
258
  setDownloadProgress(0);
@@ -405,16 +404,37 @@ const Message = ({ message }: MessageProps) => {
405
404
  src={`${cdnUrl}${media.url}`}
406
405
  alt={media.name}
407
406
  className="media-content"
408
- onClick={() => window.open(`${cdnUrl}${media.url}`, "_blank")}
407
+ onClick={() =>
408
+ window.open(`${cdnUrl}${media.url}`, "_blank")
409
+ }
409
410
  />
410
411
  ) : media.type === "video" ? (
411
- <video controls className="media-content" preload="metadata">
412
- <source
413
- src={`${cdnUrl}${media.url}`}
414
- type={`video/${`${cdnUrl}${media.url}`.split(".").pop()?.toLocaleLowerCase()}`}
415
- />
416
- </video>
412
+ <>
413
+ <div
414
+ className="video-file-preview"
415
+ onClick={() =>
416
+ window.open(`${cdnUrl}${media.url}`, "_blank")
417
+ }
418
+ >
419
+ <div className="file-icon video-icon">🎥</div>
420
+
421
+ <div className="file-info">
422
+ <span className="file-name">{media.name}</span>
423
+ <span className="file-size">
424
+ {(media.size / 1024 / 1024).toFixed(1)} MB
425
+ </span>
426
+ </div>
427
+
428
+ <div className="play-overlay">▶</div>
429
+ </div>
430
+ </>
417
431
  ) : (
432
+ // <video controls className="media-content" preload="metadata">
433
+ // <source
434
+ // src={`${cdnUrl}${media.url}`}
435
+ // type={`video/${`${cdnUrl}${media.url}`.split(".").pop()?.toLocaleLowerCase()}`}
436
+ // />
437
+ // </video>
418
438
  <div className="document-preview">
419
439
  <div className="file-icon">
420
440
  {media.type === "document" && "📄"}
@@ -434,7 +454,11 @@ const Message = ({ message }: MessageProps) => {
434
454
  if (downloadingIndex === index) {
435
455
  cancelDownload();
436
456
  } else {
437
- handleDownload(`${cdnUrl}${media.url}`, media.name, index);
457
+ handleDownload(
458
+ `${cdnUrl}/${media.url}`,
459
+ media.name,
460
+ index
461
+ );
438
462
  }
439
463
  }}
440
464
  title={downloadingIndex === index ? "Cancel" : "Download"}
@@ -491,12 +515,14 @@ const Message = ({ message }: MessageProps) => {
491
515
  );
492
516
  } else {
493
517
  setIsEditingMode(false);
518
+ setShowOptions(false);
494
519
  }
495
520
  };
496
521
 
497
522
  const handleCancelEdit = () => {
498
523
  setEditedMessage(message.message);
499
524
  setIsEditingMode(false);
525
+ setShowOptions(false);
500
526
  };
501
527
 
502
528
  const handleKeyDown = (e: React.KeyboardEvent) => {
@@ -534,7 +560,7 @@ const Message = ({ message }: MessageProps) => {
534
560
  return now.getTime() - messageDate.getTime() > oneDayInMs;
535
561
  };
536
562
 
537
- const hasMedia = message.media && message.media.length > 0;
563
+ const hasMedia = message.media && message.media.length > 0;
538
564
 
539
565
  return (
540
566
  <div className="chat-container">
@@ -548,7 +574,9 @@ const Message = ({ message }: MessageProps) => {
548
574
  >
549
575
  {message.status === "deleted" ? (
550
576
  <div className="chat-bubble compact-bubble deleted-message">
551
- <div className="message-text text-gray-900 dark:text-gray-100">This message was deleted</div>
577
+ <div className="message-text text-gray-900 dark:text-gray-100">
578
+ This message was deleted
579
+ </div>
552
580
  </div>
553
581
  ) : (
554
582
  (message.message ||
@@ -579,7 +607,9 @@ const Message = ({ message }: MessageProps) => {
579
607
  </div>
580
608
  ) : (
581
609
  message.message && (
582
- <div className="message-text text-gray-900 dark:text-gray-100">{message.message}</div>
610
+ <div className="message-text text-gray-900 dark:text-gray-100">
611
+ {message.message}
612
+ </div>
583
613
  )
584
614
  )}
585
615
 
@@ -194,8 +194,8 @@ const MessageInput = () => {
194
194
  };
195
195
 
196
196
  const getFileType = (mimeType: string): FileType | null => {
197
- if (ACCEPTED_IMAGE_TYPES.includes(mimeType)) return "image";
198
197
  if (ACCEPTED_VIDEO_TYPES.includes(mimeType)) return "video";
198
+ if (ACCEPTED_IMAGE_TYPES.includes(mimeType)) return "image";
199
199
  if (ACCEPTED_DOCUMENT_TYPES.includes(mimeType)) return "document";
200
200
  return null;
201
201
  };
@@ -1,6 +1,6 @@
1
1
  /* eslint-disable @typescript-eslint/no-explicit-any */
2
2
  import { MessageCircle } from "lucide-react";
3
- import { useEffect } from "react";
3
+ import { memo, useEffect } from "react";
4
4
  import { useChatContext } from "../../providers/ChatProvider";
5
5
  import useChatUIStore from "../../stores/Zustant";
6
6
  import { ConversationProps } from "../../types/type";
@@ -143,4 +143,4 @@ const Conversation = ({ conversation }: ConversationProps) => {
143
143
  );
144
144
  };
145
145
 
146
- export default Conversation;
146
+ export default memo(Conversation);
@@ -1,350 +1,541 @@
1
- /* eslint-disable @typescript-eslint/no-explicit-any */
2
- import { useEffect, useState } from "react";
1
+ // /* eslint-disable @typescript-eslint/no-explicit-any */
2
+ // import { useEffect, useState } from "react";
3
+ // import { useGetConversations } from "../../hooks/queries/useChatApi";
4
+ // import { useChatContext } from "../../providers/ChatProvider";
5
+ // import {
6
+ // Conversation as ConversationType,
7
+ // ParticipantGroup,
8
+ // } from "../../types/type";
9
+ // import CollapsibleSection from "../common/CollapsibleSection";
10
+ // import useChatUIStore from "../../stores/Zustant";
11
+ // import VirtualizedChatList from "../common/VirtualizedChatList";
12
+
13
+ // type GroupedServiceChats = {
14
+ // [serviceId: string]: {
15
+ // serviceTitle: string;
16
+ // conversations: ConversationType[];
17
+ // };
18
+ // };
19
+
20
+ // type Row =
21
+ // | { type: "service-header"; serviceId: string; title: string }
22
+ // | { type: "conversation"; conversation: ConversationType };
23
+
24
+ // type TabType = "personal" | "service";
25
+
26
+ // const Conversations = () => {
27
+ // const { userId, socket } = useChatContext();
28
+ // const { data: participantGroups } = useGetConversations(userId);
29
+ // const { searchTerm } = useChatUIStore();
30
+ // const [activeTab, setActiveTab] = useState<TabType>("personal");
31
+ // const [conversations, setConversations] = useState<{
32
+ // personalChats: ConversationType[];
33
+ // groupedServiceChats: GroupedServiceChats;
34
+ // }>({ personalChats: [], groupedServiceChats: {} });
35
+
36
+ // // Process fetched data
37
+ // useEffect(() => {
38
+ // if (!participantGroups) return;
39
+ // const processConversations = (groups: ParticipantGroup[]) => {
40
+ // const allPersonalChats: ConversationType[] = [];
41
+ // const allServiceChatsMap = new Map<string, GroupedServiceChats[string]>();
42
+
43
+ // groups.forEach((group) => {
44
+
45
+ // if (group.personalConversation) {
46
+ // allPersonalChats.push(group.personalConversation);
47
+ // }
48
+
49
+ // group.serviceConversations.forEach((serviceGroup) => {
50
+ // if (!allServiceChatsMap.has(serviceGroup.serviceId)) {
51
+ // allServiceChatsMap.set(serviceGroup.serviceId, {
52
+ // serviceTitle: serviceGroup.serviceTitle,
53
+ // conversations: [],
54
+ // });
55
+ // }
56
+
57
+ // const existing = allServiceChatsMap.get(serviceGroup.serviceId)!;
58
+ // existing.conversations.push(...serviceGroup.conversations);
59
+ // });
60
+ // });
61
+
62
+
63
+ // const sortConversations = (convos: ConversationType[]) =>
64
+ // convos.sort(
65
+ // (a, b) =>
66
+ // new Date(b.lastMessage?.createdAt || b.updatedAt).getTime() -
67
+ // new Date(a.lastMessage?.createdAt || a.updatedAt).getTime()
68
+ // );
69
+
70
+ // sortConversations(allPersonalChats);
71
+ // allServiceChatsMap.forEach((value) => {
72
+ // sortConversations(value.conversations);
73
+ // });
74
+
75
+ // const result = {
76
+ // personalChats: allPersonalChats,
77
+ // groupedServiceChats: Object.fromEntries(allServiceChatsMap),
78
+ // };
79
+
80
+ // return result;
81
+ // };
82
+
83
+ // setConversations(processConversations(participantGroups));
84
+ // }, [participantGroups]);
85
+
86
+ // // Real-time update listeners
87
+ // useEffect(() => {
88
+ // if (!socket) return;
89
+
90
+ // const handleMessageReadAck = (data: {
91
+ // messageIds: string[];
92
+ // chatId: string;
93
+ // }) => {
94
+ // const { chatId, messageIds } = data;
95
+
96
+ // setConversations((prev) => {
97
+ // const personalChats = [...prev.personalChats];
98
+ // const groupedServiceChats = { ...prev.groupedServiceChats };
99
+
100
+ // const updateRead = (convo: ConversationType) => {
101
+ // if (convo._id !== chatId) return convo;
102
+ // const updatedUnread = (convo.unreadMessageIds || []).filter(
103
+ // (id) => !messageIds.includes(id)
104
+ // );
105
+ // return {
106
+ // ...convo,
107
+ // unreadMessageIds: updatedUnread,
108
+ // unreadMessageCount: updatedUnread.length,
109
+ // };
110
+ // };
111
+
112
+ // const personalIndex = personalChats.findIndex((c) => c._id === chatId);
113
+ // if (personalIndex >= 0) {
114
+ // personalChats[personalIndex] = updateRead(personalChats[personalIndex]);
115
+ // return { personalChats, groupedServiceChats };
116
+ // }
117
+
118
+ // for (const serviceId in groupedServiceChats) {
119
+ // const convos = groupedServiceChats[serviceId].conversations;
120
+ // const convoIndex = convos.findIndex((c) => c._id === chatId);
121
+ // if (convoIndex >= 0) {
122
+ // const updatedConvos = [...convos];
123
+ // updatedConvos[convoIndex] = updateRead(updatedConvos[convoIndex]);
124
+ // groupedServiceChats[serviceId] = {
125
+ // ...groupedServiceChats[serviceId],
126
+ // conversations: updatedConvos,
127
+ // };
128
+ // return { personalChats, groupedServiceChats };
129
+ // }
130
+ // }
131
+
132
+ // return prev;
133
+ // });
134
+ // };
135
+
136
+ // const handleNewMessage = (newMessage: any) => {
137
+ // if (!newMessage?.conversationId) return;
138
+
139
+ // setConversations((prev) => {
140
+ // const personalChats = [...prev.personalChats];
141
+ // const groupedServiceChats = { ...prev.groupedServiceChats };
142
+
143
+ // const updateConversation = (convo: ConversationType) => ({
144
+ // ...convo,
145
+ // lastMessage: newMessage,
146
+ // updatedAt: new Date().toISOString(),
147
+ // unreadMessageIds:
148
+ // userId !== newMessage.senderId
149
+ // ? [...(convo.unreadMessageIds || []), newMessage._id]
150
+ // : convo.unreadMessageIds || [],
151
+ // unreadMessageCount:
152
+ // userId !== newMessage.senderId
153
+ // ? (convo.unreadMessageCount || 0) + 1
154
+ // : convo.unreadMessageCount || 0,
155
+ // });
156
+
157
+ // const personalIndex = personalChats.findIndex(
158
+ // (c) => c._id === newMessage.conversationId
159
+ // );
160
+ // if (personalIndex >= 0) {
161
+ // personalChats[personalIndex] = updateConversation(
162
+ // personalChats[personalIndex]
163
+ // );
164
+ // return { personalChats, groupedServiceChats };
165
+ // }
166
+
167
+ // for (const serviceId in groupedServiceChats) {
168
+ // const serviceIndex = groupedServiceChats[
169
+ // serviceId
170
+ // ].conversations.findIndex((c) => c._id === newMessage.conversationId);
171
+ // if (serviceIndex >= 0) {
172
+ // const updatedConversations = [
173
+ // ...groupedServiceChats[serviceId].conversations,
174
+ // ];
175
+ // updatedConversations[serviceIndex] = updateConversation(
176
+ // updatedConversations[serviceIndex]
177
+ // );
178
+ // groupedServiceChats[serviceId] = {
179
+ // ...groupedServiceChats[serviceId],
180
+ // conversations: updatedConversations,
181
+ // };
182
+ // return { personalChats, groupedServiceChats };
183
+ // }
184
+ // }
185
+
186
+ // personalChats.push({
187
+ // _id: newMessage.conversationId,
188
+ // participants: [newMessage.senderId, newMessage.receiverId],
189
+ // lastMessage: newMessage,
190
+ // updatedAt: new Date().toISOString(),
191
+ // unreadMessageIds:
192
+ // userId !== newMessage.senderId ? [newMessage._id] : [],
193
+ // unreadMessageCount: userId !== newMessage.senderId ? 1 : 0,
194
+ // type: "personal",
195
+ // readReceipts: [],
196
+ // createdAt: new Date().toISOString(),
197
+ // });
198
+
199
+ // return { personalChats, groupedServiceChats };
200
+ // });
201
+ // };
202
+
203
+ // const messageListener = (event: MessageEvent) => {
204
+ // try {
205
+ // const data = JSON.parse(event.data);
206
+ // if (data.event === "newMessage") {
207
+ // handleNewMessage(data.data);
208
+ // } else if (
209
+ // data.event === "messageStatusUpdated" &&
210
+ // data.data.status === "read"
211
+ // ) {
212
+ // handleMessageReadAck({
213
+ // messageIds: Array.isArray(data.data.messageId)
214
+ // ? data.data.messageId
215
+ // : [data.data.messageId],
216
+ // chatId: data.data.chatId,
217
+ // });
218
+ // }
219
+ // } catch (e) {
220
+ // console.error("Error parsing socket message:", e);
221
+ // }
222
+ // };
223
+
224
+ // socket.addEventListener("message", messageListener);
225
+ // return () => socket.removeEventListener("message", messageListener);
226
+ // }, [socket, userId]);
227
+
228
+ // // const isEmpty =
229
+ // // activeTab === "personal"
230
+ // // ? conversations.personalChats.length === 0
231
+ // // : Object.keys(conversations.groupedServiceChats).length === 0;
232
+
233
+ // const lowerSearch = searchTerm?.toLowerCase().trim();
234
+
235
+ // // Filter personal chats
236
+ // const filteredPersonalChats = !lowerSearch
237
+ // ? conversations.personalChats
238
+ // : conversations.personalChats.filter((convo) =>
239
+ // convo.participantDetails?.some((p: any) =>
240
+ // p?.name?.toLowerCase().includes(lowerSearch)
241
+ // )
242
+ // );
243
+
244
+ // // Filter service chats
245
+ // const filteredGroupedServiceChats: GroupedServiceChats = !lowerSearch
246
+ // ? conversations.groupedServiceChats
247
+ // : Object.fromEntries(
248
+ // Object.entries(conversations.groupedServiceChats)
249
+ // .map(([serviceId, group]) => {
250
+ // const filteredConvos = group.conversations.filter((convo) =>
251
+ // convo.participantDetails?.some((p: any) =>
252
+ // p?.name?.toLowerCase().includes(lowerSearch)
253
+ // ) ||
254
+ // group.serviceTitle?.toLowerCase().includes(lowerSearch) ||
255
+ // convo.serviceTitle?.toLowerCase().includes(lowerSearch)
256
+ // );
257
+ // return [serviceId, { ...group, conversations: filteredConvos }];
258
+ // })
259
+ // .filter(([_, group]) => group.conversations.length > 0)
260
+ // );
261
+
262
+ // // Improved empty state logic
263
+ // const showPersonalTab = activeTab === "personal" && filteredPersonalChats.length > 0;
264
+ // const showServiceTab = activeTab === "service" && Object.keys(filteredGroupedServiceChats).length > 0;
265
+ // const isEmpty = !showPersonalTab && !showServiceTab;
266
+
267
+
268
+ // // Calculate unread counts for tabs
269
+ // const personalUnreadCount = conversations.personalChats.reduce(
270
+ // (total, convo) => total + (convo.unreadMessageCount || 0),
271
+ // 0
272
+ // );
273
+
274
+ // const serviceUnreadCount = Object.values(conversations.groupedServiceChats)
275
+ // .flatMap(group => group.conversations)
276
+ // .reduce((total, convo) => total + (convo.unreadMessageCount || 0), 0);
277
+
278
+ // return (
279
+ // <div className="chatSidebarConversations">
280
+ // {/* Tab Navigation */}
281
+ // <div className="flex border-b border-gray-200">
282
+ // <button
283
+ // className={`flex-1 py-3 px-4 text-sm font-medium text-center border-b-2 transition-colors ${
284
+ // activeTab === "personal"
285
+ // ? "border-primary text-primary "
286
+ // : "border-transparent text-gray-500 hover:text-gray-700"
287
+ // }`}
288
+ // onClick={() => setActiveTab("personal")}
289
+ // >
290
+ // Personal
291
+ // {personalUnreadCount > 0 && (
292
+ // <span className="ml-2 bg-blue-primary text-white text-xs rounded-full px-2 py-1 min-w-5 inline-flex justify-center">
293
+ // {personalUnreadCount}
294
+ // </span>
295
+ // )}
296
+ // </button>
297
+ // <button
298
+ // className={`flex-1 py-3 px-4 text-sm font-medium text-center border-b-2 transition-colors ${
299
+ // activeTab === "service"
300
+ // ? "border-primary text-primary "
301
+ // : "border-transparent text-gray-500 hover:text-gray-700"
302
+ // }`}
303
+ // onClick={() => setActiveTab("service")}
304
+ // >
305
+ // Service
306
+ // {serviceUnreadCount > 0 && (
307
+ // <span className="ml-2 bg-primary text-white text-xs rounded-full px-2 py-1 min-w-5 inline-flex justify-center">
308
+ // {serviceUnreadCount}
309
+ // </span>
310
+ // )}
311
+ // </button>
312
+ // </div>
313
+
314
+ // {/* Tab Content */}
315
+ // <div className="flex-1 overflow-auto">
316
+ // {isEmpty ? (
317
+ // <div className="flex flex-col items-center justify-center p-8 text-center">
318
+ // <h3 className="text-xl font-semibold mb-1">No Conversations</h3>
319
+ // <p className="text-gray-500 text-sm mb-4">
320
+ // {activeTab === "personal"
321
+ // ? "You have no personal messages yet."
322
+ // : "You have no service messages yet."}
323
+ // </p>
324
+ // </div>
325
+ // ) : (
326
+ // <>
327
+ // <div className="h-full overflow-auto">
328
+ // {activeTab === "personal" && filteredPersonalChats.length > 0 && (
329
+ // <VirtualizedChatList conversations={filteredPersonalChats} />
330
+ // )}
331
+
332
+ // {activeTab === "service" &&
333
+ // Object.entries(filteredGroupedServiceChats).length > 0 && (
334
+ // <div className="p-2">
335
+ // {Object.entries(filteredGroupedServiceChats).map(
336
+ // ([serviceId, { serviceTitle, conversations: serviceConvos }]) => (
337
+ // <CollapsibleSection
338
+ // key={serviceId}
339
+ // title={serviceTitle}
340
+ // defaultOpen={false}
341
+ // >
342
+ // <VirtualizedChatList conversations={serviceConvos} />
343
+ // </CollapsibleSection>
344
+ // )
345
+ // )}
346
+ // </div>
347
+ // )}
348
+ // </div>
349
+
350
+ // </>
351
+ // )}
352
+ // </div>
353
+ // </div>
354
+ // );
355
+ // };
356
+
357
+ // export default Conversations;
358
+
359
+
360
+
361
+ /* Optimized single-virtualizer version for 10k+ conversations */
362
+
363
+ import { useEffect, useMemo, useRef, useState } from "react";
364
+ import { useVirtualizer } from "@tanstack/react-virtual";
3
365
  import { useGetConversations } from "../../hooks/queries/useChatApi";
4
366
  import { useChatContext } from "../../providers/ChatProvider";
5
- import {
6
- Conversation as ConversationType,
7
- ParticipantGroup,
8
- } from "../../types/type";
9
- import CollapsibleSection from "../common/CollapsibleSection";
10
367
  import useChatUIStore from "../../stores/Zustant";
11
- import VirtualizedChatList from "../common/VirtualizedChatList";
368
+ import Conversation from "../sidebar/Conversation";
369
+ import { Conversation as ConversationType, ParticipantGroup } from "../../types/type";
12
370
 
13
- type GroupedServiceChats = {
14
- [serviceId: string]: {
15
- serviceTitle: string;
16
- conversations: ConversationType[];
17
- };
18
- };
371
+ // ---------- Row model ----------
372
+ type Row =
373
+ | { type: "service-header"; serviceId: string; title: string }
374
+ | { type: "conversation"; conversation: ConversationType };
19
375
 
20
376
  type TabType = "personal" | "service";
21
377
 
22
- const Conversations = () => {
378
+ const HEADER_HEIGHT = 36;
379
+ const ROW_HEIGHT = 60;
380
+
381
+ export default function Conversations() {
23
382
  const { userId, socket } = useChatContext();
24
383
  const { data: participantGroups } = useGetConversations(userId);
25
384
  const { searchTerm } = useChatUIStore();
26
- const [activeTab, setActiveTab] = useState<TabType>("personal");
27
- const [conversations, setConversations] = useState<{
28
- personalChats: ConversationType[];
29
- groupedServiceChats: GroupedServiceChats;
30
- }>({ personalChats: [], groupedServiceChats: {} });
31
-
32
- // Process fetched data
33
- useEffect(() => {
34
- if (!participantGroups) return;
35
- const processConversations = (groups: ParticipantGroup[]) => {
36
- const allPersonalChats: ConversationType[] = [];
37
- const allServiceChatsMap = new Map<string, GroupedServiceChats[string]>();
38
-
39
- groups.forEach((group, groupIndex) => {
40
-
41
- if (group.personalConversation) {
42
- allPersonalChats.push(group.personalConversation);
43
- }
44
-
45
- group.serviceConversations.forEach((serviceGroup, serviceIndex) => {
46
- if (!allServiceChatsMap.has(serviceGroup.serviceId)) {
47
- allServiceChatsMap.set(serviceGroup.serviceId, {
48
- serviceTitle: serviceGroup.serviceTitle,
49
- conversations: [],
50
- });
51
- }
52
-
53
- const existing = allServiceChatsMap.get(serviceGroup.serviceId)!;
54
- existing.conversations.push(...serviceGroup.conversations);
55
- });
56
- });
57
385
 
386
+ const [activeTab, setActiveTab] = useState<TabType>("personal");
387
+ const [personalChats, setPersonalChats] = useState<ConversationType[]>([]);
388
+ const [serviceChats, setServiceChats] = useState<
389
+ Record<string, { serviceTitle: string; conversations: ConversationType[] }>
390
+ >({});
58
391
 
59
- const sortConversations = (convos: ConversationType[]) =>
60
- convos.sort(
61
- (a, b) =>
62
- new Date(b.lastMessage?.createdAt || b.updatedAt).getTime() -
63
- new Date(a.lastMessage?.createdAt || a.updatedAt).getTime()
64
- );
65
-
66
- sortConversations(allPersonalChats);
67
- allServiceChatsMap.forEach((value, key) => {
68
- sortConversations(value.conversations);
69
- });
70
-
71
- const result = {
72
- personalChats: allPersonalChats,
73
- groupedServiceChats: Object.fromEntries(allServiceChatsMap),
74
- };
392
+ // -------- Normalize API data (once) --------
393
+ useEffect(() => {
394
+ if (!participantGroups) return;
75
395
 
76
- return result;
77
- };
396
+ const personals: ConversationType[] = [];
397
+ const services: Record<string, { serviceTitle: string; conversations: ConversationType[] }> = {};
78
398
 
79
- setConversations(processConversations(participantGroups));
80
- }, [participantGroups]);
399
+ participantGroups.forEach((group: ParticipantGroup) => {
400
+ if (group.personalConversation) personals.push(group.personalConversation);
81
401
 
82
- // Real-time update listeners
83
- useEffect(() => {
84
- if (!socket) return;
85
-
86
- const handleMessageReadAck = (data: {
87
- messageIds: string[];
88
- chatId: string;
89
- }) => {
90
- const { chatId, messageIds } = data;
91
-
92
- setConversations((prev) => {
93
- const personalChats = [...prev.personalChats];
94
- const groupedServiceChats = { ...prev.groupedServiceChats };
95
-
96
- const updateRead = (convo: ConversationType) => {
97
- if (convo._id !== chatId) return convo;
98
- const updatedUnread = (convo.unreadMessageIds || []).filter(
99
- (id) => !messageIds.includes(id)
100
- );
101
- return {
102
- ...convo,
103
- unreadMessageIds: updatedUnread,
104
- unreadMessageCount: updatedUnread.length,
402
+ group.serviceConversations.forEach((sg) => {
403
+ if (!services[sg.serviceId]) {
404
+ services[sg.serviceId] = {
405
+ serviceTitle: sg.serviceTitle,
406
+ conversations: [],
105
407
  };
106
- };
107
-
108
- const personalIndex = personalChats.findIndex((c) => c._id === chatId);
109
- if (personalIndex >= 0) {
110
- personalChats[personalIndex] = updateRead(personalChats[personalIndex]);
111
- return { personalChats, groupedServiceChats };
112
- }
113
-
114
- for (const serviceId in groupedServiceChats) {
115
- const convos = groupedServiceChats[serviceId].conversations;
116
- const convoIndex = convos.findIndex((c) => c._id === chatId);
117
- if (convoIndex >= 0) {
118
- const updatedConvos = [...convos];
119
- updatedConvos[convoIndex] = updateRead(updatedConvos[convoIndex]);
120
- groupedServiceChats[serviceId] = {
121
- ...groupedServiceChats[serviceId],
122
- conversations: updatedConvos,
123
- };
124
- return { personalChats, groupedServiceChats };
125
- }
126
408
  }
127
-
128
- return prev;
409
+ services[sg.serviceId].conversations.push(...sg.conversations);
129
410
  });
130
- };
131
-
132
- const handleNewMessage = (newMessage: any) => {
133
- if (!newMessage?.conversationId) return;
134
-
135
- setConversations((prev) => {
136
- const personalChats = [...prev.personalChats];
137
- const groupedServiceChats = { ...prev.groupedServiceChats };
138
-
139
- const updateConversation = (convo: ConversationType) => ({
140
- ...convo,
141
- lastMessage: newMessage,
142
- updatedAt: new Date().toISOString(),
143
- unreadMessageIds:
144
- userId !== newMessage.senderId
145
- ? [...(convo.unreadMessageIds || []), newMessage._id]
146
- : convo.unreadMessageIds || [],
147
- unreadMessageCount:
148
- userId !== newMessage.senderId
149
- ? (convo.unreadMessageCount || 0) + 1
150
- : convo.unreadMessageCount || 0,
151
- });
152
-
153
- const personalIndex = personalChats.findIndex(
154
- (c) => c._id === newMessage.conversationId
155
- );
156
- if (personalIndex >= 0) {
157
- personalChats[personalIndex] = updateConversation(
158
- personalChats[personalIndex]
159
- );
160
- return { personalChats, groupedServiceChats };
161
- }
162
-
163
- for (const serviceId in groupedServiceChats) {
164
- const serviceIndex = groupedServiceChats[
165
- serviceId
166
- ].conversations.findIndex((c) => c._id === newMessage.conversationId);
167
- if (serviceIndex >= 0) {
168
- const updatedConversations = [
169
- ...groupedServiceChats[serviceId].conversations,
170
- ];
171
- updatedConversations[serviceIndex] = updateConversation(
172
- updatedConversations[serviceIndex]
173
- );
174
- groupedServiceChats[serviceId] = {
175
- ...groupedServiceChats[serviceId],
176
- conversations: updatedConversations,
177
- };
178
- return { personalChats, groupedServiceChats };
179
- }
180
- }
411
+ });
181
412
 
182
- personalChats.push({
183
- _id: newMessage.conversationId,
184
- participants: [newMessage.senderId, newMessage.receiverId],
185
- lastMessage: newMessage,
186
- updatedAt: new Date().toISOString(),
187
- unreadMessageIds:
188
- userId !== newMessage.senderId ? [newMessage._id] : [],
189
- unreadMessageCount: userId !== newMessage.senderId ? 1 : 0,
190
- type: "personal",
191
- readReceipts: [],
192
- createdAt: new Date().toISOString(),
193
- });
194
-
195
- return { personalChats, groupedServiceChats };
196
- });
197
- };
198
-
199
- const messageListener = (event: MessageEvent) => {
200
- try {
201
- const data = JSON.parse(event.data);
202
- if (data.event === "newMessage") {
203
- handleNewMessage(data.data);
204
- } else if (
205
- data.event === "messageStatusUpdated" &&
206
- data.data.status === "read"
207
- ) {
208
- handleMessageReadAck({
209
- messageIds: Array.isArray(data.data.messageId)
210
- ? data.data.messageId
211
- : [data.data.messageId],
212
- chatId: data.data.chatId,
213
- });
214
- }
215
- } catch (e) {
216
- console.error("Error parsing socket message:", e);
217
- }
218
- };
413
+ const sortFn = (a: ConversationType, b: ConversationType) =>
414
+ new Date(b.lastMessage?.createdAt || b.updatedAt).getTime() -
415
+ new Date(a.lastMessage?.createdAt || a.updatedAt).getTime();
219
416
 
220
- socket.addEventListener("message", messageListener);
221
- return () => socket.removeEventListener("message", messageListener);
222
- }, [socket, userId]);
417
+ personals.sort(sortFn);
418
+ Object.values(services).forEach((g) => g.conversations.sort(sortFn));
223
419
 
224
- // const isEmpty =
225
- // activeTab === "personal"
226
- // ? conversations.personalChats.length === 0
227
- // : Object.keys(conversations.groupedServiceChats).length === 0;
420
+ setPersonalChats(personals);
421
+ setServiceChats(services);
422
+ }, [participantGroups]);
228
423
 
424
+ // -------- Search filtering --------
229
425
  const lowerSearch = searchTerm?.toLowerCase().trim();
230
426
 
231
- // Filter personal chats
232
- const filteredPersonalChats = !lowerSearch
233
- ? conversations.personalChats
234
- : conversations.personalChats.filter((convo) =>
235
- convo.participantDetails?.some((p: any) =>
427
+ const filteredPersonal = useMemo(() => {
428
+ if (!lowerSearch) return personalChats;
429
+ return personalChats.filter((c) =>
430
+ c.participantDetails?.some((p: any) =>
236
431
  p?.name?.toLowerCase().includes(lowerSearch)
237
432
  )
238
433
  );
434
+ }, [personalChats, lowerSearch]);
435
+
436
+ const filteredService = useMemo(() => {
437
+ if (!lowerSearch) return serviceChats;
438
+
439
+ const result: typeof serviceChats = {};
440
+ Object.entries(serviceChats).forEach(([sid, group]) => {
441
+ const convs = group.conversations.filter((c) =>
442
+ c.participantDetails?.some((p: any) =>
443
+ p?.name?.toLowerCase().includes(lowerSearch)
444
+ ) ||
445
+ group.serviceTitle.toLowerCase().includes(lowerSearch)
446
+ );
447
+ if (convs.length) result[sid] = { ...group, conversations: convs };
448
+ });
449
+ return result;
450
+ }, [serviceChats, lowerSearch]);
239
451
 
240
- // Filter service chats
241
- const filteredGroupedServiceChats: GroupedServiceChats = !lowerSearch
242
- ? conversations.groupedServiceChats
243
- : Object.fromEntries(
244
- Object.entries(conversations.groupedServiceChats)
245
- .map(([serviceId, group]) => {
246
- const filteredConvos = group.conversations.filter((convo) =>
247
- convo.participantDetails?.some((p: any) =>
248
- p?.name?.toLowerCase().includes(lowerSearch)
249
- ) ||
250
- group.serviceTitle?.toLowerCase().includes(lowerSearch) ||
251
- convo.serviceTitle?.toLowerCase().includes(lowerSearch)
252
- );
253
- return [serviceId, { ...group, conversations: filteredConvos }];
254
- })
255
- .filter(([_, group]) => group.conversations.length > 0)
256
- );
452
+ // -------- Flatten into ONE list --------
453
+ const rows: Row[] = useMemo(() => {
454
+ const out: Row[] = [];
257
455
 
258
- // Improved empty state logic
259
- const showPersonalTab = activeTab === "personal" && filteredPersonalChats.length > 0;
260
- const showServiceTab = activeTab === "service" && Object.keys(filteredGroupedServiceChats).length > 0;
261
- const isEmpty = !showPersonalTab && !showServiceTab;
262
-
456
+ if (activeTab === "personal") {
457
+ filteredPersonal.forEach((c) =>
458
+ out.push({ type: "conversation", conversation: c })
459
+ );
460
+ }
263
461
 
264
- // Calculate unread counts for tabs
265
- const personalUnreadCount = conversations.personalChats.reduce(
266
- (total, convo) => total + (convo.unreadMessageCount || 0),
267
- 0
268
- );
462
+ if (activeTab === "service") {
463
+ Object.entries(filteredService).forEach(([sid, group]) => {
464
+ out.push({ type: "service-header", serviceId: sid, title: group.serviceTitle });
465
+ group.conversations.forEach((c) =>
466
+ out.push({ type: "conversation", conversation: c })
467
+ );
468
+ });
469
+ }
269
470
 
270
- const serviceUnreadCount = Object.values(conversations.groupedServiceChats)
271
- .flatMap(group => group.conversations)
272
- .reduce((total, convo) => total + (convo.unreadMessageCount || 0), 0);
471
+ return out;
472
+ }, [activeTab, filteredPersonal, filteredService]);
273
473
 
474
+ // -------- Virtualizer --------
475
+ const parentRef = useRef<HTMLDivElement>(null);
476
+
477
+ const virtualizer = useVirtualizer({
478
+ count: rows.length,
479
+ getScrollElement: () => parentRef.current,
480
+ estimateSize: (index) =>
481
+ rows[index]?.type === "service-header" ? HEADER_HEIGHT : ROW_HEIGHT,
482
+ overscan: 10,
483
+ });
484
+
485
+ // -------- UI --------
274
486
  return (
275
- <div className="chatSidebarConversations">
276
- {/* Tab Navigation */}
277
- <div className="flex border-b border-gray-200">
487
+ <div className="flex flex-col h-full">
488
+ {/* Tabs */}
489
+ <div className="flex border-b">
278
490
  <button
279
- className={`flex-1 py-3 px-4 text-sm font-medium text-center border-b-2 transition-colors ${
280
- activeTab === "personal"
281
- ? "border-primary text-primary "
282
- : "border-transparent text-gray-500 hover:text-gray-700"
283
- }`}
491
+ className={`flex-1 py-3 ${activeTab === "personal" ? "border-b-2 border-primary" : ""}`}
284
492
  onClick={() => setActiveTab("personal")}
285
493
  >
286
494
  Personal
287
- {personalUnreadCount > 0 && (
288
- <span className="ml-2 bg-blue-primary text-white text-xs rounded-full px-2 py-1 min-w-5 inline-flex justify-center">
289
- {personalUnreadCount}
290
- </span>
291
- )}
292
495
  </button>
293
496
  <button
294
- className={`flex-1 py-3 px-4 text-sm font-medium text-center border-b-2 transition-colors ${
295
- activeTab === "service"
296
- ? "border-primary text-primary "
297
- : "border-transparent text-gray-500 hover:text-gray-700"
298
- }`}
497
+ className={`flex-1 py-3 ${activeTab === "service" ? "border-b-2 border-primary" : ""}`}
299
498
  onClick={() => setActiveTab("service")}
300
499
  >
301
500
  Service
302
- {serviceUnreadCount > 0 && (
303
- <span className="ml-2 bg-primary text-white text-xs rounded-full px-2 py-1 min-w-5 inline-flex justify-center">
304
- {serviceUnreadCount}
305
- </span>
306
- )}
307
501
  </button>
308
502
  </div>
309
503
 
310
- {/* Tab Content */}
311
- <div className="flex-1 overflow-auto">
312
- {isEmpty ? (
313
- <div className="flex flex-col items-center justify-center p-8 text-center">
314
- <h3 className="text-xl font-semibold mb-1">No Conversations</h3>
315
- <p className="text-gray-500 text-sm mb-4">
316
- {activeTab === "personal"
317
- ? "You have no personal messages yet."
318
- : "You have no service messages yet."}
319
- </p>
320
- </div>
321
- ) : (
322
- <>
323
- {activeTab === "personal" && filteredPersonalChats.length > 0 && (
324
- <VirtualizedChatList conversations={filteredPersonalChats} />
325
- )}
326
-
327
- {activeTab === "service" &&
328
- Object.entries(filteredGroupedServiceChats).length > 0 && (
329
- <div className="p-2">
330
- {Object.entries(filteredGroupedServiceChats).map(
331
- ([serviceId, { serviceTitle, conversations: serviceConvos }]) => (
332
- <CollapsibleSection
333
- key={serviceId}
334
- title={serviceTitle}
335
- defaultOpen={false}
336
- >
337
- <VirtualizedChatList conversations={serviceConvos} />
338
- </CollapsibleSection>
339
- )
504
+ {/* Single scroll container */}
505
+ <div ref={parentRef} className="flex-1 overflow-auto">
506
+ <div
507
+ style={{
508
+ height: virtualizer.getTotalSize(),
509
+ position: "relative",
510
+ }}
511
+ >
512
+ {virtualizer.getVirtualItems().map((item) => {
513
+ const row = rows[item.index];
514
+ if (!row) return null;
515
+
516
+ return (
517
+ <div
518
+ key={item.key}
519
+ style={{
520
+ position: "absolute",
521
+ top: 0,
522
+ left: 0,
523
+ width: "100%",
524
+ transform: `translateY(${item.start}px)`,
525
+ }}
526
+ >
527
+ {row.type === "service-header" ? (
528
+ <div className="px-3 py-2 text-xs font-semibold uppercase bg-gray-100 dark:bg-gray-800">
529
+ {row.title}
530
+ </div>
531
+ ) : (
532
+ <Conversation conversation={row.conversation} />
340
533
  )}
341
534
  </div>
342
- )}
343
- </>
344
- )}
535
+ );
536
+ })}
537
+ </div>
345
538
  </div>
346
539
  </div>
347
540
  );
348
- };
349
-
350
- export default Conversations;
541
+ }