2026-02-08 22:44:06 +05:30
|
|
|
import { io, Socket } from 'socket.io-client';
|
|
|
|
|
import type {
|
|
|
|
|
Message,
|
|
|
|
|
TypingIndicator,
|
|
|
|
|
UserStatusChange,
|
|
|
|
|
MessagesReadEvent,
|
2026-03-28 02:03:04 +05:30
|
|
|
MessageDeliveredEvent,
|
2026-02-08 22:44:06 +05:30
|
|
|
CreateMessageDto,
|
|
|
|
|
} from '@/types/messaging';
|
|
|
|
|
|
|
|
|
|
type MessageHandler = (message: Message) => void;
|
|
|
|
|
type TypingHandler = (data: TypingIndicator) => void;
|
|
|
|
|
type StatusHandler = (data: UserStatusChange) => void;
|
|
|
|
|
type ReadHandler = (data: MessagesReadEvent) => void;
|
2026-03-28 02:03:04 +05:30
|
|
|
type DeliveredHandler = (data: MessageDeliveredEvent) => void;
|
2026-03-28 15:34:09 +05:30
|
|
|
type NotificationEventHandler = (data: Record<string, unknown>) => void;
|
2026-02-08 22:44:06 +05:30
|
|
|
|
|
|
|
|
class SocketService {
|
|
|
|
|
private socket: Socket | null = null;
|
|
|
|
|
private reconnectAttempts = 0;
|
|
|
|
|
private maxReconnectAttempts = 5;
|
|
|
|
|
private messageHandlers: Set<MessageHandler> = new Set();
|
|
|
|
|
private typingStartHandlers: Set<TypingHandler> = new Set();
|
|
|
|
|
private typingStopHandlers: Set<TypingHandler> = new Set();
|
|
|
|
|
private statusHandlers: Set<StatusHandler> = new Set();
|
|
|
|
|
private readHandlers: Set<ReadHandler> = new Set();
|
2026-03-28 02:03:04 +05:30
|
|
|
private deliveredHandlers: Set<DeliveredHandler> = new Set();
|
2026-03-28 15:34:09 +05:30
|
|
|
private connectionRequestHandlers: Set<NotificationEventHandler> = new Set();
|
|
|
|
|
private connectionResponseHandlers: Set<NotificationEventHandler> = new Set();
|
2026-02-08 22:44:06 +05:30
|
|
|
private connectionHandlers: Set<(connected: boolean) => void> = new Set();
|
2026-02-11 04:47:51 +05:30
|
|
|
private authErrorHandlers: Set<() => void> = new Set();
|
2026-02-08 22:44:06 +05:30
|
|
|
private isConnecting = false;
|
2026-02-11 04:47:51 +05:30
|
|
|
private currentToken: string | null = null;
|
2026-03-29 03:31:01 +05:30
|
|
|
private listenersAttached = false;
|
|
|
|
|
private connectPromise: Promise<void> | null = null;
|
2026-02-08 22:44:06 +05:30
|
|
|
|
|
|
|
|
connect(token: string): Promise<void> {
|
2026-03-29 03:31:01 +05:30
|
|
|
this.currentToken = token;
|
2026-02-11 04:47:51 +05:30
|
|
|
|
2026-03-29 03:31:01 +05:30
|
|
|
// Already connected with same token
|
|
|
|
|
if (this.socket?.connected) {
|
|
|
|
|
return Promise.resolve();
|
|
|
|
|
}
|
2026-02-08 22:44:06 +05:30
|
|
|
|
2026-03-29 03:31:01 +05:30
|
|
|
// Already connecting — return the existing promise
|
|
|
|
|
if (this.isConnecting && this.connectPromise) {
|
|
|
|
|
return this.connectPromise;
|
|
|
|
|
}
|
2026-02-08 22:44:06 +05:30
|
|
|
|
2026-03-29 03:31:01 +05:30
|
|
|
this.isConnecting = true;
|
2026-02-08 22:44:06 +05:30
|
|
|
|
2026-03-29 03:31:01 +05:30
|
|
|
// Clean up existing socket if any
|
|
|
|
|
if (this.socket && !this.socket.connected) {
|
|
|
|
|
this.socket.removeAllListeners();
|
|
|
|
|
this.socket.disconnect();
|
|
|
|
|
this.socket = null;
|
|
|
|
|
this.listenersAttached = false;
|
|
|
|
|
}
|
2026-02-08 22:44:06 +05:30
|
|
|
|
2026-03-29 03:31:01 +05:30
|
|
|
// 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,
|
|
|
|
|
});
|
2026-02-08 22:44:06 +05:30
|
|
|
|
2026-03-29 03:31:01 +05:30
|
|
|
this.connectPromise = new Promise<void>((resolve, reject) => {
|
|
|
|
|
this.socket!.on('connect', () => {
|
2026-02-08 22:44:06 +05:30
|
|
|
console.log('Socket connected:', this.socket?.id);
|
|
|
|
|
this.isConnecting = false;
|
2026-03-29 03:31:01 +05:30
|
|
|
this.connectPromise = null;
|
2026-02-08 22:44:06 +05:30
|
|
|
this.reconnectAttempts = 0;
|
|
|
|
|
this.notifyConnectionHandlers(true);
|
|
|
|
|
resolve();
|
|
|
|
|
});
|
|
|
|
|
|
2026-03-29 03:31:01 +05:30
|
|
|
this.socket!.on('disconnect', (reason) => {
|
2026-02-08 22:44:06 +05:30
|
|
|
console.log('Socket disconnected:', reason);
|
|
|
|
|
this.isConnecting = false;
|
2026-03-29 03:31:01 +05:30
|
|
|
this.connectPromise = null;
|
2026-02-08 22:44:06 +05:30
|
|
|
this.notifyConnectionHandlers(false);
|
2026-02-11 04:47:51 +05:30
|
|
|
|
|
|
|
|
// "io server disconnect" means the server rejected us (likely auth failure)
|
|
|
|
|
if (reason === 'io server disconnect') {
|
|
|
|
|
console.warn('Socket: Server disconnected us (likely token expired). Requesting token refresh...');
|
|
|
|
|
this.authErrorHandlers.forEach((handler) => handler());
|
|
|
|
|
}
|
2026-02-08 22:44:06 +05:30
|
|
|
});
|
|
|
|
|
|
2026-03-29 03:31:01 +05:30
|
|
|
this.socket!.on('connect_error', (error) => {
|
2026-02-08 22:44:06 +05:30
|
|
|
console.error('Socket connection error:', error.message);
|
|
|
|
|
this.isConnecting = false;
|
2026-03-29 03:31:01 +05:30
|
|
|
this.connectPromise = null;
|
2026-02-08 22:44:06 +05:30
|
|
|
this.reconnectAttempts++;
|
|
|
|
|
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
|
|
|
|
|
reject(new Error('Max reconnection attempts reached'));
|
|
|
|
|
}
|
|
|
|
|
});
|
2026-03-29 03:31:01 +05:30
|
|
|
});
|
2026-02-08 22:44:06 +05:30
|
|
|
|
2026-03-29 03:31:01 +05:30
|
|
|
// Attach event listeners only once per socket instance
|
|
|
|
|
this.attachEventListeners();
|
2026-02-08 22:44:06 +05:30
|
|
|
|
2026-03-29 03:31:01 +05:30
|
|
|
return this.connectPromise;
|
|
|
|
|
}
|
2026-02-08 22:44:06 +05:30
|
|
|
|
2026-03-29 03:31:01 +05:30
|
|
|
/**
|
|
|
|
|
* Attach socket event listeners — called once per socket instance
|
|
|
|
|
*/
|
|
|
|
|
private attachEventListeners(): void {
|
|
|
|
|
if (this.listenersAttached || !this.socket) return;
|
|
|
|
|
this.listenersAttached = true;
|
2026-02-08 22:44:06 +05:30
|
|
|
|
2026-03-29 03:31:01 +05:30
|
|
|
this.socket.on('new_message', (message: Message) => {
|
|
|
|
|
this.messageHandlers.forEach((handler) => handler(message));
|
|
|
|
|
});
|
2026-02-08 22:44:06 +05:30
|
|
|
|
2026-03-29 03:31:01 +05:30
|
|
|
this.socket.on('typing_start', (data: TypingIndicator) => {
|
|
|
|
|
this.typingStartHandlers.forEach((handler) => handler(data));
|
|
|
|
|
});
|
2026-03-28 02:03:04 +05:30
|
|
|
|
2026-03-29 03:31:01 +05:30
|
|
|
this.socket.on('typing_stop', (data: TypingIndicator) => {
|
|
|
|
|
this.typingStopHandlers.forEach((handler) => handler(data));
|
|
|
|
|
});
|
2026-03-28 15:34:09 +05:30
|
|
|
|
2026-03-29 03:31:01 +05:30
|
|
|
this.socket.on('user_status_change', (data: UserStatusChange) => {
|
|
|
|
|
this.statusHandlers.forEach((handler) => handler(data));
|
|
|
|
|
});
|
2026-03-28 15:34:09 +05:30
|
|
|
|
2026-03-29 03:31:01 +05:30
|
|
|
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<string, unknown>) => {
|
|
|
|
|
this.connectionRequestHandlers.forEach((handler) => handler(data));
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
this.socket.on('connection_response', (data: Record<string, unknown>) => {
|
|
|
|
|
this.connectionResponseHandlers.forEach((handler) => handler(data));
|
2026-02-08 22:44:06 +05:30
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-11 04:47:51 +05:30
|
|
|
// Reconnect with a fresh token (used when the previous token expired)
|
|
|
|
|
reconnectWithToken(token: string): void {
|
|
|
|
|
this.currentToken = token;
|
|
|
|
|
if (this.socket) {
|
2026-03-29 03:31:01 +05:30
|
|
|
// Ensure fully disconnected before reconnecting
|
|
|
|
|
this.socket.removeAllListeners();
|
|
|
|
|
this.socket.disconnect();
|
|
|
|
|
this.socket = null;
|
|
|
|
|
this.listenersAttached = false;
|
2026-02-11 04:47:51 +05:30
|
|
|
}
|
2026-03-29 03:31:01 +05:30
|
|
|
this.isConnecting = false;
|
|
|
|
|
this.connectPromise = null;
|
|
|
|
|
this.connect(token).catch((err) => {
|
|
|
|
|
console.error('Socket reconnect with new token failed:', err);
|
|
|
|
|
});
|
2026-02-11 04:47:51 +05:30
|
|
|
}
|
|
|
|
|
|
2026-02-08 22:44:06 +05:30
|
|
|
disconnect(): void {
|
|
|
|
|
if (this.socket) {
|
2026-03-29 03:31:01 +05:30
|
|
|
this.socket.removeAllListeners();
|
2026-02-08 22:44:06 +05:30
|
|
|
this.socket.disconnect();
|
|
|
|
|
this.socket = null;
|
|
|
|
|
}
|
2026-02-11 04:47:51 +05:30
|
|
|
this.currentToken = null;
|
2026-03-17 18:03:07 +05:30
|
|
|
this.isConnecting = false;
|
2026-03-29 03:31:01 +05:30
|
|
|
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();
|
2026-02-08 22:44:06 +05:30
|
|
|
}
|
|
|
|
|
|
|
|
|
|
isConnected(): boolean {
|
|
|
|
|
return this.socket?.connected || false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Room management
|
|
|
|
|
joinConversation(conversationId: string): Promise<{ success: boolean; error?: string }> {
|
|
|
|
|
return new Promise((resolve) => {
|
|
|
|
|
if (!this.socket) {
|
|
|
|
|
resolve({ success: false, error: 'Not connected' });
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-02-11 07:36:40 +05:30
|
|
|
|
|
|
|
|
const timeout = setTimeout(() => {
|
|
|
|
|
resolve({ success: false, error: 'Socket timeout' });
|
|
|
|
|
}, 10000);
|
|
|
|
|
|
2026-02-08 22:44:06 +05:30
|
|
|
this.socket.emit('join_conversation', { conversationId }, (response: { success: boolean; error?: string }) => {
|
2026-02-11 07:36:40 +05:30
|
|
|
clearTimeout(timeout);
|
2026-02-08 22:44:06 +05:30
|
|
|
resolve(response);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
leaveConversation(conversationId: string): void {
|
|
|
|
|
if (this.socket) {
|
|
|
|
|
this.socket.emit('leave_conversation', { conversationId });
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Messaging
|
|
|
|
|
sendMessage(
|
|
|
|
|
conversationId: string,
|
|
|
|
|
message: CreateMessageDto
|
|
|
|
|
): Promise<{ success: boolean; message?: Message; error?: string }> {
|
|
|
|
|
return new Promise((resolve) => {
|
|
|
|
|
if (!this.socket) {
|
|
|
|
|
resolve({ success: false, error: 'Not connected' });
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-02-11 07:36:40 +05:30
|
|
|
|
|
|
|
|
const timeout = setTimeout(() => {
|
|
|
|
|
resolve({ success: false, error: 'Socket timeout' });
|
|
|
|
|
}, 10000);
|
|
|
|
|
|
2026-02-08 22:44:06 +05:30
|
|
|
this.socket.emit(
|
|
|
|
|
'send_message',
|
|
|
|
|
{ conversationId, message },
|
|
|
|
|
(response: { success: boolean; message?: Message; error?: string }) => {
|
2026-02-11 07:36:40 +05:30
|
|
|
clearTimeout(timeout);
|
2026-02-08 22:44:06 +05:30
|
|
|
resolve(response);
|
|
|
|
|
}
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Typing indicators
|
|
|
|
|
startTyping(conversationId: string): void {
|
|
|
|
|
if (this.socket) {
|
|
|
|
|
this.socket.emit('typing_start', { conversationId });
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
stopTyping(conversationId: string): void {
|
|
|
|
|
if (this.socket) {
|
|
|
|
|
this.socket.emit('typing_stop', { conversationId });
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Mark messages as read
|
|
|
|
|
markAsRead(conversationId: string): Promise<{ success: boolean; error?: string }> {
|
|
|
|
|
return new Promise((resolve) => {
|
|
|
|
|
if (!this.socket) {
|
|
|
|
|
resolve({ success: false, error: 'Not connected' });
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-02-11 07:36:40 +05:30
|
|
|
|
|
|
|
|
const timeout = setTimeout(() => {
|
|
|
|
|
resolve({ success: false, error: 'Socket timeout' });
|
|
|
|
|
}, 10000);
|
|
|
|
|
|
2026-02-08 22:44:06 +05:30
|
|
|
this.socket.emit('mark_read', { conversationId }, (response: { success: boolean; error?: string }) => {
|
2026-02-11 07:36:40 +05:30
|
|
|
clearTimeout(timeout);
|
2026-02-08 22:44:06 +05:30
|
|
|
resolve(response);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Event handlers registration
|
|
|
|
|
onNewMessage(handler: MessageHandler): () => void {
|
|
|
|
|
this.messageHandlers.add(handler);
|
|
|
|
|
return () => this.messageHandlers.delete(handler);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
onTypingStart(handler: TypingHandler): () => void {
|
|
|
|
|
this.typingStartHandlers.add(handler);
|
|
|
|
|
return () => this.typingStartHandlers.delete(handler);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
onTypingStop(handler: TypingHandler): () => void {
|
|
|
|
|
this.typingStopHandlers.add(handler);
|
|
|
|
|
return () => this.typingStopHandlers.delete(handler);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
onUserStatusChange(handler: StatusHandler): () => void {
|
|
|
|
|
this.statusHandlers.add(handler);
|
|
|
|
|
return () => this.statusHandlers.delete(handler);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
onMessagesRead(handler: ReadHandler): () => void {
|
|
|
|
|
this.readHandlers.add(handler);
|
|
|
|
|
return () => this.readHandlers.delete(handler);
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-28 02:03:04 +05:30
|
|
|
onMessageDelivered(handler: DeliveredHandler): () => void {
|
|
|
|
|
this.deliveredHandlers.add(handler);
|
|
|
|
|
return () => this.deliveredHandlers.delete(handler);
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-28 15:34:09 +05:30
|
|
|
onConnectionRequest(handler: NotificationEventHandler): () => void {
|
|
|
|
|
this.connectionRequestHandlers.add(handler);
|
|
|
|
|
return () => this.connectionRequestHandlers.delete(handler);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
onConnectionResponse(handler: NotificationEventHandler): () => void {
|
|
|
|
|
this.connectionResponseHandlers.add(handler);
|
|
|
|
|
return () => this.connectionResponseHandlers.delete(handler);
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-08 22:44:06 +05:30
|
|
|
onConnectionChange(handler: (connected: boolean) => void): () => void {
|
|
|
|
|
this.connectionHandlers.add(handler);
|
|
|
|
|
return () => this.connectionHandlers.delete(handler);
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-11 04:47:51 +05:30
|
|
|
onAuthError(handler: () => void): () => void {
|
|
|
|
|
this.authErrorHandlers.add(handler);
|
|
|
|
|
return () => this.authErrorHandlers.delete(handler);
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-08 22:44:06 +05:30
|
|
|
private notifyConnectionHandlers(connected: boolean): void {
|
|
|
|
|
this.connectionHandlers.forEach((handler) => handler(connected));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Singleton instance
|
|
|
|
|
export const socketService = new SocketService();
|