Files
frontend/src/services/socket.service.ts

235 lines
7.0 KiB
TypeScript

import { io, Socket } from 'socket.io-client';
import type {
Message,
TypingIndicator,
UserStatusChange,
MessagesReadEvent,
CreateMessageDto,
} from '@/types/messaging';
type MessageHandler = (message: Message) => void;
type TypingHandler = (data: TypingIndicator) => void;
type StatusHandler = (data: UserStatusChange) => void;
type ReadHandler = (data: MessagesReadEvent) => void;
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();
private connectionHandlers: Set<(connected: boolean) => void> = new Set();
private isConnecting = false;
connect(token: string): Promise<void> {
return new Promise((resolve, reject) => {
// Already connected
if (this.socket?.connected) {
resolve();
return;
}
// 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;
}
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;
}
// 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.on('connect', () => {
console.log('Socket connected:', this.socket?.id);
this.isConnecting = false;
this.reconnectAttempts = 0;
this.notifyConnectionHandlers(true);
resolve();
});
this.socket.on('disconnect', (reason) => {
console.log('Socket disconnected:', reason);
this.isConnecting = false;
this.notifyConnectionHandlers(false);
});
this.socket.on('connect_error', (error) => {
console.error('Socket connection error:', error.message);
this.isConnecting = false;
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));
});
this.socket.on('typing_start', (data: TypingIndicator) => {
this.typingStartHandlers.forEach((handler) => handler(data));
});
this.socket.on('typing_stop', (data: TypingIndicator) => {
this.typingStopHandlers.forEach((handler) => handler(data));
});
this.socket.on('user_status_change', (data: UserStatusChange) => {
this.statusHandlers.forEach((handler) => handler(data));
});
this.socket.on('messages_read', (data: MessagesReadEvent) => {
this.readHandlers.forEach((handler) => handler(data));
});
});
}
disconnect(): void {
if (this.socket) {
this.socket.disconnect();
this.socket = null;
}
}
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;
}
this.socket.emit('join_conversation', { conversationId }, (response: { success: boolean; error?: string }) => {
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;
}
this.socket.emit(
'send_message',
{ conversationId, message },
(response: { success: boolean; message?: Message; error?: string }) => {
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;
}
this.socket.emit('mark_read', { conversationId }, (response: { success: boolean; error?: string }) => {
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);
}
onConnectionChange(handler: (connected: boolean) => void): () => void {
this.connectionHandlers.add(handler);
return () => this.connectionHandlers.delete(handler);
}
private notifyConnectionHandlers(connected: boolean): void {
this.connectionHandlers.forEach((handler) => handler(connected));
}
}
// Singleton instance
export const socketService = new SocketService();