diff --git a/src/components/profile/SpecializationCard.tsx b/src/components/profile/SpecializationCard.tsx
index 1e45e02..c8209a6 100644
--- a/src/components/profile/SpecializationCard.tsx
+++ b/src/components/profile/SpecializationCard.tsx
@@ -29,11 +29,11 @@ export function SpecializationCard({
};
return (
-
+
{icon}
{title}
- {items.slice(0, initialItemsToShow).map((item, idx) => (
+ {displayedItems.map((item, idx) => (
{item}
@@ -56,35 +56,6 @@ export function SpecializationCard({
)}
- {/* Expanded overlay showing all items */}
- {isExpanded && hasMoreItems && (
-
-
{icon}
-
{title}
-
- {items.map((item, idx) => (
-
- {item}
-
- ))}
-
-
-
-
-
- )}
);
}
diff --git a/src/hooks/useMessaging.ts b/src/hooks/useMessaging.ts
index 0cad3a5..9900e5f 100644
--- a/src/hooks/useMessaging.ts
+++ b/src/hooks/useMessaging.ts
@@ -345,7 +345,9 @@ export function useMessaging(options: UseMessagingOptions = {}): UseMessagingRet
async (conversationId: string) => {
await messagesService.deleteConversation(conversationId);
- // Remove from list
+ // Remove from list and invalidate cache
+ _cachedConversations = null;
+ _cachedTimestamp = 0;
setConversations((prev) => prev.filter((c) => c.id !== conversationId));
// Clear current if it was the deleted one
@@ -592,9 +594,22 @@ export function useMessaging(options: UseMessagingOptions = {}): UseMessagingRet
unsubscribeStatus();
unsubscribeRead();
unsubscribeDelivered();
+ // Clean up typing timeout to prevent memory leak
+ if (typingTimeoutRef.current) {
+ clearTimeout(typingTimeoutRef.current);
+ typingTimeoutRef.current = null;
+ }
};
}, [updateUserStatus]);
+ // Invalidate cache when session changes (logout/login)
+ useEffect(() => {
+ if (!isAuthenticated) {
+ _cachedConversations = null;
+ _cachedTimestamp = 0;
+ }
+ }, [isAuthenticated]);
+
// Auto-connect on mount
useEffect(() => {
if (autoConnect && isAuthenticated && !isInitializedRef.current) {
diff --git a/src/services/socket.service.ts b/src/services/socket.service.ts
index ab8c7e2..cce83a0 100644
--- a/src/services/socket.service.ts
+++ b/src/services/socket.service.ts
@@ -31,68 +31,59 @@ class SocketService {
private authErrorHandlers: Set<() => void> = new Set();
private isConnecting = false;
private currentToken: string | null = null;
+ private listenersAttached = false;
+ private connectPromise: Promise
| null = null;
connect(token: string): Promise {
- return new Promise((resolve, reject) => {
- this.currentToken = token;
+ this.currentToken = token;
- // Already connected with same token
- if (this.socket?.connected) {
- resolve();
- return;
- }
+ // Already connected with same token
+ if (this.socket?.connected) {
+ return Promise.resolve();
+ }
- // Already connecting
- if (this.isConnecting) {
- // Wait for existing connection attempt
- const checkConnection = setInterval(() => {
- if (this.socket?.connected) {
- clearInterval(checkConnection);
- resolve();
- }
- }, 100);
- setTimeout(() => {
- clearInterval(checkConnection);
- if (!this.socket?.connected) {
- reject(new Error('Connection timeout'));
- }
- }, 10000);
- return;
- }
+ // Already connecting — return the existing promise
+ if (this.isConnecting && this.connectPromise) {
+ return this.connectPromise;
+ }
- this.isConnecting = true;
+ this.isConnecting = true;
- // Clean up existing socket if any (but not if it's connecting)
- if (this.socket && !this.socket.connected) {
- this.socket.removeAllListeners();
- this.socket.disconnect();
- this.socket = null;
- }
+ // Clean up existing socket if any
+ if (this.socket && !this.socket.connected) {
+ this.socket.removeAllListeners();
+ this.socket.disconnect();
+ this.socket = null;
+ this.listenersAttached = false;
+ }
- // Extract base URL without /api/v1 path for Socket.io connection
- const apiUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000';
- const baseUrl = apiUrl.replace(/\/api\/v1\/?$/, '');
+ // Extract base URL without /api/v1 path for Socket.io connection
+ const apiUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000';
+ const baseUrl = apiUrl.replace(/\/api\/v1\/?$/, '');
- this.socket = io(baseUrl, {
- auth: { token },
- transports: ['websocket', 'polling'],
- reconnection: true,
- reconnectionAttempts: this.maxReconnectAttempts,
- reconnectionDelay: 1000,
- reconnectionDelayMax: 5000,
- });
+ this.socket = io(baseUrl, {
+ auth: { token },
+ transports: ['websocket', 'polling'],
+ reconnection: true,
+ reconnectionAttempts: this.maxReconnectAttempts,
+ reconnectionDelay: 1000,
+ reconnectionDelayMax: 5000,
+ });
- this.socket.on('connect', () => {
+ this.connectPromise = new Promise((resolve, reject) => {
+ this.socket!.on('connect', () => {
console.log('Socket connected:', this.socket?.id);
this.isConnecting = false;
+ this.connectPromise = null;
this.reconnectAttempts = 0;
this.notifyConnectionHandlers(true);
resolve();
});
- this.socket.on('disconnect', (reason) => {
+ this.socket!.on('disconnect', (reason) => {
console.log('Socket disconnected:', reason);
this.isConnecting = false;
+ this.connectPromise = null;
this.notifyConnectionHandlers(false);
// "io server disconnect" means the server rejected us (likely auth failure)
@@ -102,47 +93,60 @@ class SocketService {
}
});
- this.socket.on('connect_error', (error) => {
+ this.socket!.on('connect_error', (error) => {
console.error('Socket connection error:', error.message);
this.isConnecting = false;
+ this.connectPromise = null;
this.reconnectAttempts++;
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
reject(new Error('Max reconnection attempts reached'));
}
});
+ });
- // Set up event listeners
- this.socket.on('new_message', (message: Message) => {
- this.messageHandlers.forEach((handler) => handler(message));
- });
+ // Attach event listeners only once per socket instance
+ this.attachEventListeners();
- this.socket.on('typing_start', (data: TypingIndicator) => {
- this.typingStartHandlers.forEach((handler) => handler(data));
- });
+ return this.connectPromise;
+ }
- this.socket.on('typing_stop', (data: TypingIndicator) => {
- this.typingStopHandlers.forEach((handler) => handler(data));
- });
+ /**
+ * Attach socket event listeners — called once per socket instance
+ */
+ private attachEventListeners(): void {
+ if (this.listenersAttached || !this.socket) return;
+ this.listenersAttached = true;
- this.socket.on('user_status_change', (data: UserStatusChange) => {
- this.statusHandlers.forEach((handler) => handler(data));
- });
+ this.socket.on('new_message', (message: Message) => {
+ this.messageHandlers.forEach((handler) => handler(message));
+ });
- this.socket.on('messages_read', (data: MessagesReadEvent) => {
- this.readHandlers.forEach((handler) => handler(data));
- });
+ this.socket.on('typing_start', (data: TypingIndicator) => {
+ this.typingStartHandlers.forEach((handler) => handler(data));
+ });
- this.socket.on('message_delivered', (data: MessageDeliveredEvent) => {
- this.deliveredHandlers.forEach((handler) => handler(data));
- });
+ this.socket.on('typing_stop', (data: TypingIndicator) => {
+ this.typingStopHandlers.forEach((handler) => handler(data));
+ });
- this.socket.on('connection_request', (data: Record) => {
- this.connectionRequestHandlers.forEach((handler) => handler(data));
- });
+ this.socket.on('user_status_change', (data: UserStatusChange) => {
+ this.statusHandlers.forEach((handler) => handler(data));
+ });
- this.socket.on('connection_response', (data: Record) => {
- this.connectionResponseHandlers.forEach((handler) => handler(data));
- });
+ this.socket.on('messages_read', (data: MessagesReadEvent) => {
+ this.readHandlers.forEach((handler) => handler(data));
+ });
+
+ this.socket.on('message_delivered', (data: MessageDeliveredEvent) => {
+ this.deliveredHandlers.forEach((handler) => handler(data));
+ });
+
+ this.socket.on('connection_request', (data: Record) => {
+ this.connectionRequestHandlers.forEach((handler) => handler(data));
+ });
+
+ this.socket.on('connection_response', (data: Record) => {
+ this.connectionResponseHandlers.forEach((handler) => handler(data));
});
}
@@ -150,23 +154,40 @@ class SocketService {
reconnectWithToken(token: string): void {
this.currentToken = token;
if (this.socket) {
- // Update the auth token for reconnection
- this.socket.auth = { token };
- this.socket.connect();
- } else {
- this.connect(token).catch((err) => {
- console.error('Socket reconnect with new token failed:', err);
- });
+ // Ensure fully disconnected before reconnecting
+ this.socket.removeAllListeners();
+ this.socket.disconnect();
+ this.socket = null;
+ this.listenersAttached = false;
}
+ this.isConnecting = false;
+ this.connectPromise = null;
+ this.connect(token).catch((err) => {
+ console.error('Socket reconnect with new token failed:', err);
+ });
}
disconnect(): void {
if (this.socket) {
+ this.socket.removeAllListeners();
this.socket.disconnect();
this.socket = null;
}
this.currentToken = null;
this.isConnecting = false;
+ this.connectPromise = null;
+ this.listenersAttached = false;
+ // Clear all handler sets to prevent leaks
+ this.messageHandlers.clear();
+ this.typingStartHandlers.clear();
+ this.typingStopHandlers.clear();
+ this.statusHandlers.clear();
+ this.readHandlers.clear();
+ this.deliveredHandlers.clear();
+ this.connectionRequestHandlers.clear();
+ this.connectionResponseHandlers.clear();
+ this.connectionHandlers.clear();
+ this.authErrorHandlers.clear();
}
isConnected(): boolean {
@@ -209,7 +230,6 @@ class SocketService {
return;
}
- // Timeout fallback in case acknowledgement never arrives
const timeout = setTimeout(() => {
resolve({ success: false, error: 'Socket timeout' });
}, 10000);