@pubuduth-aplicy/chat-ui 2.2.13 → 2.2.15

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.2.13",
3
+ "version": "2.2.15",
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": "",
@@ -5,7 +5,7 @@ import { Sidebar } from "./sidebar/Sidebar";
5
5
  import { useChatContext } from "../providers/ChatProvider";
6
6
 
7
7
  export const Chat = () => {
8
- const { setMessages, selectedConversation, updateMessageStatus } = useChatUIStore();
8
+ const { selectedConversation, updateMessageStatus } = useChatUIStore();
9
9
 
10
10
  const { socket, sendMessage } = useChatContext();
11
11
 
@@ -25,7 +25,7 @@ export const Chat = () => {
25
25
  event: "confirmDelivery",
26
26
  data: {
27
27
  messageIds: [message._id],
28
- chatId: message.conversationId, // make sure this is sent from backend
28
+ chatId: message.conversationId,
29
29
  senderRole: message.senderRole,
30
30
  receiverRole: message.receiverRole,
31
31
  senderId: message.senderId,
@@ -38,9 +38,6 @@ export const Chat = () => {
38
38
 
39
39
  // Only append to the message list if the message belongs to the currently open conversation
40
40
  if (message.conversationId === selectedConversation?._id) {
41
- setMessages((prev) => [...prev, message]);
42
-
43
- // Send read receipt only when the user is viewing this conversation
44
41
  sendMessage({
45
42
  event: "messageRead",
46
43
  data: {
@@ -61,7 +58,7 @@ export const Chat = () => {
61
58
 
62
59
  socket.addEventListener("message", handleMessage);
63
60
  return () => socket.removeEventListener("message", handleMessage);
64
- }, [socket, setMessages, sendMessage, updateMessageStatus, selectedConversation]);
61
+ }, [socket, sendMessage, updateMessageStatus, selectedConversation]);
65
62
 
66
63
  return (
67
64
  <div className="container mx-auto mb-5">
@@ -16,8 +16,9 @@ const MessageContainer = () => {
16
16
  const { role } = getChatConfig();
17
17
 
18
18
  useEffect(() => {
19
- if (selectedConversation?._id && socket?.readyState === WebSocket.OPEN) {
20
- // Send joinChat command to server via WebSocket
19
+ if (!socket || !selectedConversation?._id) return;
20
+
21
+ const sendJoinChat = () => {
21
22
  socket.send(
22
23
  JSON.stringify({
23
24
  event: "joinChat",
@@ -28,8 +29,35 @@ const MessageContainer = () => {
28
29
  },
29
30
  })
30
31
  );
32
+
33
+ // Bulk-mark all existing unread messages as read when the conversation is opened
34
+ const unreadIds = selectedConversation.unreadMessageIds ?? [];
35
+ const lastMsg = selectedConversation.lastMessage;
36
+ if (unreadIds.length > 0 && lastMsg) {
37
+ socket.send(
38
+ JSON.stringify({
39
+ event: "messageRead",
40
+ data: {
41
+ messageIds: unreadIds,
42
+ chatId: selectedConversation._id,
43
+ senderId: lastMsg.senderId,
44
+ receiverId: userId,
45
+ // roles are not always available here — backend should infer from chatId if needed
46
+ },
47
+ })
48
+ );
49
+ }
50
+ };
51
+
52
+ if (socket.readyState === WebSocket.OPEN) {
53
+ // Socket already open — fire immediately
54
+ sendJoinChat();
55
+ } else if (socket.readyState === WebSocket.CONNECTING) {
56
+ // Socket still connecting — wait for it to open, then fire once
57
+ socket.addEventListener("open", sendJoinChat, { once: true });
58
+ return () => socket.removeEventListener("open", sendJoinChat);
31
59
  }
32
- }, [selectedConversation?._id, socket]);
60
+ }, [selectedConversation?._id, socket, userId, role]);
33
61
 
34
62
  useEffect(() => {
35
63
  if (!socket) return;
@@ -39,7 +39,7 @@ const MessageInput = () => {
39
39
  const apiClient = getApiClient();
40
40
  const { role } = getChatConfig();
41
41
  const { socket, sendMessage, userId } = useChatContext();
42
- const { selectedConversation, setMessages } = useChatUIStore();
42
+ const { selectedConversation, setMessages, setLastSentMessage } = useChatUIStore();
43
43
 
44
44
  const [message, setMessage] = useState("");
45
45
  const [message1, setMessage1] = useState("");
@@ -47,8 +47,6 @@ const MessageInput = () => {
47
47
 
48
48
  const [typingUser, setTypingUser] = useState<string | null>(null);
49
49
  const [isSending, setIsSending] = useState(false);
50
- // eslint-disable-next-line @typescript-eslint/no-unused-vars
51
- const [isTyping, setIsTyping] = useState(false);
52
50
  const [attachments, setAttachments] = useState<Attachment[]>([]);
53
51
  const [showAttachmentOptions, setShowAttachmentOptions] = useState(false);
54
52
 
@@ -474,6 +472,8 @@ const MessageInput = () => {
474
472
  },
475
473
  {
476
474
  onSuccess: (data) => {
475
+ const confirmedMessage = data[1];
476
+
477
477
  setMessages((prev) => {
478
478
  const filtered = prev.filter(
479
479
  (msg) => msg._id !== tempMessageId
@@ -481,20 +481,32 @@ const MessageInput = () => {
481
481
  return [
482
482
  ...filtered,
483
483
  {
484
- ...data[1],
484
+ ...confirmedMessage,
485
485
  isUploading: false,
486
486
  isOptimistic: false,
487
487
  },
488
488
  ];
489
489
  });
490
490
 
491
+ // Update the sidebar last message for the sender immediately
492
+ setLastSentMessage({
493
+ _id: confirmedMessage._id,
494
+ conversationId: selectedConversation?._id ?? "",
495
+ message: message1,
496
+ senderId: userId,
497
+ media: successfulUploads,
498
+ status: confirmedMessage.status ?? "sent",
499
+ createdAt: confirmedMessage.createdAt ?? new Date().toISOString(),
500
+ updatedAt: confirmedMessage.updatedAt ?? new Date().toISOString(),
501
+ });
502
+
491
503
  // Send message via WebSocket
492
504
  sendMessage({
493
505
  event: "sendMessage",
494
506
  data: {
495
507
  chatId: selectedConversation?._id,
496
508
  message: message1,
497
- messageId: data[1]._id,
509
+ messageId: confirmedMessage._id,
498
510
  attachments: successfulUploads,
499
511
  senderId: userId,
500
512
  receiverId: otherParticipant._id,
@@ -160,13 +160,16 @@ const Conversation = ({ conversation }: ConversationProps) => {
160
160
  </div>
161
161
  {/* <span className="text-xs text-gray-500">{lastMessageTimestamp}</span> */}
162
162
  </div>
163
- <p className="text-xs text-gray-600 truncate dark:text-gray-500">
164
- {conversation.lastMessage?.status === "deleted"
165
- ? "This message was deleted"
166
- : conversation.lastMessage?.media?.length > 0
167
- ? "Attachment"
168
- : conversation.lastMessage?.message}
169
- </p>
163
+ <p className="text-xs text-gray-600 dark:text-gray-500">
164
+ {conversation.lastMessage?.status === "deleted"
165
+ ? "This message was deleted"
166
+ : conversation.lastMessage?.media?.length > 0
167
+ ? "Attachment"
168
+ : conversation.lastMessage?.message?.length > 30
169
+ ? conversation.lastMessage.message.slice(0, 30) + "..."
170
+ : conversation.lastMessage?.message}
171
+ </p>
172
+
170
173
  </div>
171
174
  </div>
172
175
  );
@@ -1,5 +1,5 @@
1
1
  /* eslint-disable @typescript-eslint/no-explicit-any */
2
- import { useEffect, useState } from "react";
2
+ import { useCallback, useEffect, useState } from "react";
3
3
  import { useGetConversations } from "../../hooks/queries/useChatApi";
4
4
  import { useChatContext } from "../../providers/ChatProvider";
5
5
  import {
@@ -22,7 +22,7 @@ type TabType = "personal" | "service";
22
22
  const Conversations = () => {
23
23
  const { userId, socket } = useChatContext();
24
24
  const { data: participantGroups } = useGetConversations(userId);
25
- const { searchTerm } = useChatUIStore();
25
+ const { searchTerm, lastSentMessage, setLastSentMessage } = useChatUIStore();
26
26
  const [activeTab, setActiveTab] = useState<TabType>("personal");
27
27
  const [conversations, setConversations] = useState<{
28
28
  personalChats: ConversationType[];
@@ -77,6 +77,68 @@ const Conversations = () => {
77
77
  setConversations(processConversations(participantGroups));
78
78
  }, [participantGroups]);
79
79
 
80
+ // ── Hoisted to component scope so both the socket listener AND the
81
+ // lastSentMessage effect can call it ─────────────────────────────
82
+ const handleNewMessage = useCallback((newMessage: any) => {
83
+ if (!newMessage?.conversationId) return;
84
+
85
+ setConversations((prev) => {
86
+ const personalChats = [...prev.personalChats];
87
+ const groupedServiceChats = { ...prev.groupedServiceChats };
88
+
89
+ const updateConversation = (convo: ConversationType) => ({
90
+ ...convo,
91
+ lastMessage: newMessage,
92
+ updatedAt: new Date().toISOString(),
93
+ unreadMessageIds:
94
+ userId !== newMessage.senderId
95
+ ? [...(convo.unreadMessageIds || []), newMessage._id]
96
+ : convo.unreadMessageIds || [],
97
+ unreadMessageCount:
98
+ userId !== newMessage.senderId
99
+ ? (convo.unreadMessageCount || 0) + 1
100
+ : convo.unreadMessageCount || 0,
101
+ });
102
+
103
+ const personalIndex = personalChats.findIndex(
104
+ (c) => c._id === newMessage.conversationId
105
+ );
106
+ if (personalIndex >= 0) {
107
+ personalChats[personalIndex] = updateConversation(personalChats[personalIndex]);
108
+ return { personalChats, groupedServiceChats };
109
+ }
110
+
111
+ for (const serviceId in groupedServiceChats) {
112
+ const serviceIndex = groupedServiceChats[serviceId].conversations.findIndex(
113
+ (c) => c._id === newMessage.conversationId
114
+ );
115
+ if (serviceIndex >= 0) {
116
+ const updatedConversations = [...groupedServiceChats[serviceId].conversations];
117
+ updatedConversations[serviceIndex] = updateConversation(updatedConversations[serviceIndex]);
118
+ groupedServiceChats[serviceId] = {
119
+ ...groupedServiceChats[serviceId],
120
+ conversations: updatedConversations,
121
+ };
122
+ return { personalChats, groupedServiceChats };
123
+ }
124
+ }
125
+
126
+ // Fallback: brand-new conversation not yet in local state
127
+ personalChats.push({
128
+ _id: newMessage.conversationId,
129
+ participants: [newMessage.senderId, newMessage.receiverId],
130
+ lastMessage: newMessage,
131
+ updatedAt: new Date().toISOString(),
132
+ unreadMessageIds: userId !== newMessage.senderId ? [newMessage._id] : [],
133
+ unreadMessageCount: userId !== newMessage.senderId ? 1 : 0,
134
+ type: "personal",
135
+ createdAt: new Date().toISOString(),
136
+ } as any);
137
+
138
+ return { personalChats, groupedServiceChats };
139
+ });
140
+ }, [userId]);
141
+
80
142
  // Real-time update listeners
81
143
  useEffect(() => {
82
144
  if (!socket) return;
@@ -129,87 +191,18 @@ const Conversations = () => {
129
191
  });
130
192
  };
131
193
 
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
- }
181
-
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
194
  const messageListener = (event: MessageEvent) => {
200
195
  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
- ) {
196
+ const parsed = JSON.parse(event.data);
197
+
198
+ if (parsed.event === "newMessage") {
199
+ // Actual message is nested at parsed.data.data — same structure as Messages.tsx
200
+ handleNewMessage(parsed?.data?.data);
201
+ } else if (parsed.event === "messageStatusUpdated" && parsed.data?.status === "read") {
202
+ const rawId = parsed.data.messageId ?? parsed.data.messageIds;
208
203
  handleMessageReadAck({
209
- messageIds: Array.isArray(data.data.messageId)
210
- ? data.data.messageId
211
- : [data.data.messageId],
212
- chatId: data.data.chatId,
204
+ messageIds: Array.isArray(rawId) ? rawId : [rawId].filter(Boolean),
205
+ chatId: parsed.data.chatId,
213
206
  });
214
207
  }
215
208
  } catch (e) {
@@ -219,7 +212,14 @@ const Conversations = () => {
219
212
 
220
213
  socket.addEventListener("message", messageListener);
221
214
  return () => socket.removeEventListener("message", messageListener);
222
- }, [socket, userId]);
215
+ }, [socket, handleNewMessage]);
216
+
217
+ // React to messages sent by the current user (no server echo needed)
218
+ useEffect(() => {
219
+ if (!lastSentMessage) return;
220
+ handleNewMessage(lastSentMessage);
221
+ setLastSentMessage(null);
222
+ }, [lastSentMessage, handleNewMessage, setLastSentMessage]);
223
223
 
224
224
  // const isEmpty =
225
225
  // activeTab === "personal"
@@ -231,11 +231,12 @@ const Conversations = () => {
231
231
  // Filter personal chats
232
232
  const filteredPersonalChats = !lowerSearch
233
233
  ? conversations.personalChats
234
- : conversations.personalChats.filter((convo) =>
235
- convo.participantDetails?.some((p: any) =>
236
- p?.name?.toLowerCase().includes(lowerSearch)
237
- )
238
- );
234
+ : conversations.personalChats.filter((convo) => {
235
+ const details = Array.isArray(convo.participantDetails)
236
+ ? convo.participantDetails
237
+ : [convo.participantDetails].filter(Boolean);
238
+ return details.some((p: any) => p?.name?.toLowerCase().includes(lowerSearch));
239
+ });
239
240
 
240
241
  // Filter service chats
241
242
  const filteredGroupedServiceChats: GroupedServiceChats = !lowerSearch
@@ -243,17 +244,21 @@ const Conversations = () => {
243
244
  : Object.fromEntries(
244
245
  Object.entries(conversations.groupedServiceChats)
245
246
  .map(([serviceId, group]) => {
247
+ const details = (convo: ConversationType) => {
248
+ const d = Array.isArray(convo.participantDetails)
249
+ ? convo.participantDetails
250
+ : [convo.participantDetails].filter(Boolean);
251
+ return d.some((p: any) => p?.name?.toLowerCase().includes(lowerSearch));
252
+ };
246
253
  const filteredConvos = group.conversations.filter(
247
254
  (convo) =>
248
- convo.participantDetails?.some((p: any) =>
249
- p?.name?.toLowerCase().includes(lowerSearch)
250
- ) ||
255
+ details(convo) ||
251
256
  group.serviceTitle?.toLowerCase().includes(lowerSearch) ||
252
257
  convo.serviceTitle?.toLowerCase().includes(lowerSearch)
253
258
  );
254
- return [serviceId, { ...group, conversations: filteredConvos }];
259
+ return [serviceId, { ...group, conversations: filteredConvos }] as [string, GroupedServiceChats[string]];
255
260
  })
256
- .filter(([_, group]) => group.conversations.length > 0)
261
+ .filter(([, group]) => group.conversations.length > 0)
257
262
  );
258
263
 
259
264
  // Improved empty state logic
@@ -2,6 +2,18 @@
2
2
  import { FileType } from "../components/common/FilePreview";
3
3
  import { create } from "zustand";
4
4
 
5
+ /** Minimal shape needed to update the sidebar after a send */
6
+ export interface SentMessageSignal {
7
+ _id: string;
8
+ conversationId: string;
9
+ message: string;
10
+ senderId: string;
11
+ media?: any[];
12
+ status: string;
13
+ createdAt: string;
14
+ updatedAt: string;
15
+ }
16
+
5
17
  interface ChatUIState {
6
18
  isChatOpen: boolean;
7
19
  unreadCount: number;
@@ -51,6 +63,9 @@ interface ChatUIState {
51
63
  setOnlineUsers: (users: string[]) => void;
52
64
  searchTerm: string;
53
65
  setSearchTerm: (searchTerm: string) => void;
66
+ /** Signal published by MessageInput after a successful send so the sidebar can update */
67
+ lastSentMessage: SentMessageSignal | null;
68
+ setLastSentMessage: (msg: SentMessageSignal | null) => void;
54
69
  }
55
70
 
56
71
  const useChatUIStore = create<ChatUIState>((set) => ({
@@ -74,6 +89,8 @@ const useChatUIStore = create<ChatUIState>((set) => ({
74
89
  setOnlineUsers: (users) => set({ onlineUsers: users }),
75
90
  searchTerm: "",
76
91
  setSearchTerm: (searchTerm) => set({ searchTerm }),
92
+ lastSentMessage: null,
93
+ setLastSentMessage: (msg) => set({ lastSentMessage: msg }),
77
94
  }));
78
95
 
79
96
  export default useChatUIStore;