feat: Implement real-time messaging functionality with dedicated services, types, a custom hook, and UI integration.
This commit is contained in:
@@ -47,3 +47,7 @@ export type {
|
||||
TwoFactorLoginResponse,
|
||||
TwoFactorRequiredResponse,
|
||||
} from './two-factor.service';
|
||||
|
||||
// Messaging Services
|
||||
export { socketService } from './socket.service';
|
||||
export { messagesService } from './messages.service';
|
||||
|
||||
86
src/services/messages.service.ts
Normal file
86
src/services/messages.service.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import api from './api';
|
||||
import type {
|
||||
Conversation,
|
||||
Message,
|
||||
MessagesResponse,
|
||||
CreateMessageDto,
|
||||
} from '@/types/messaging';
|
||||
|
||||
interface ApiResponse<T> {
|
||||
success: boolean;
|
||||
data: T;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
class MessagesService {
|
||||
/**
|
||||
* Get all conversations for the current user
|
||||
*/
|
||||
async getConversations(): Promise<Conversation[]> {
|
||||
const response = await api.get<ApiResponse<Conversation[]>>('/messages/conversations');
|
||||
return response.data.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start or get existing conversation with an agent
|
||||
*/
|
||||
async startConversation(agentProfileId: string): Promise<Conversation> {
|
||||
const response = await api.post<ApiResponse<Conversation>>('/messages/conversations', {
|
||||
agentProfileId,
|
||||
});
|
||||
return response.data.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a specific conversation
|
||||
*/
|
||||
async getConversation(conversationId: string): Promise<Conversation> {
|
||||
const response = await api.get<ApiResponse<Conversation>>(
|
||||
`/messages/conversations/${conversationId}`
|
||||
);
|
||||
return response.data.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get messages for a conversation with pagination
|
||||
*/
|
||||
async getMessages(
|
||||
conversationId: string,
|
||||
page: number = 1,
|
||||
limit: number = 50
|
||||
): Promise<MessagesResponse> {
|
||||
const response = await api.get<ApiResponse<MessagesResponse>>(
|
||||
`/messages/conversations/${conversationId}/messages`,
|
||||
{ params: { page, limit } }
|
||||
);
|
||||
return response.data.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a message via REST API (fallback for WebSocket)
|
||||
*/
|
||||
async sendMessage(conversationId: string, dto: CreateMessageDto): Promise<Message> {
|
||||
const response = await api.post<ApiResponse<Message>>(
|
||||
`/messages/conversations/${conversationId}/messages`,
|
||||
dto
|
||||
);
|
||||
return response.data.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark all messages in a conversation as read
|
||||
*/
|
||||
async markAsRead(conversationId: string): Promise<void> {
|
||||
await api.patch(`/messages/conversations/${conversationId}/read`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get total unread message count
|
||||
*/
|
||||
async getUnreadCount(): Promise<number> {
|
||||
const response = await api.get<ApiResponse<{ unreadCount: number }>>('/messages/unread-count');
|
||||
return response.data.data.unreadCount;
|
||||
}
|
||||
}
|
||||
|
||||
export const messagesService = new MessagesService();
|
||||
234
src/services/socket.service.ts
Normal file
234
src/services/socket.service.ts
Normal file
@@ -0,0 +1,234 @@
|
||||
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();
|
||||
Reference in New Issue
Block a user