- Connect to /notifications namespace (matches backend NotificationsGateway) - Pass JWT token in Socket.IO auth handshake for proper authentication - Listen for server-pushed notification:unread-count to sync badge - Show sonner toast on notification:new events - Add setUnreadCount action to notifications store - Add E2E round-trip tests (auth connect, reject invalid, multi-device) - Fix inquiry handler test: event name inquiry.created → inquiry.received Co-Authored-By: Paperclip <noreply@paperclip.ing>
110 lines
3.4 KiB
TypeScript
110 lines
3.4 KiB
TypeScript
'use client';
|
|
|
|
import { useEffect, useRef, useCallback } from 'react';
|
|
import { io, type Socket } from 'socket.io-client';
|
|
import { toast } from 'sonner';
|
|
import { useAuthStore } from '@/lib/auth-store';
|
|
import type { NotificationDto } from '@/lib/notifications-api';
|
|
import { useNotificationsStore } from '@/lib/notifications-store';
|
|
|
|
/** Base URL for the Socket.IO server (without namespace). */
|
|
const SOCKET_URL =
|
|
process.env['NEXT_PUBLIC_API_URL']?.replace('/api/v1', '') ||
|
|
'http://localhost:3001';
|
|
|
|
/**
|
|
* Hook that manages the Socket.IO connection for real-time notifications.
|
|
*
|
|
* Connects to the `/notifications` namespace on the backend
|
|
* {@link NotificationsGateway} with JWT auth handshake.
|
|
*
|
|
* - Authenticates via `auth.token` (access-token from cookie or store)
|
|
* - Listens for `notification:new` → adds to store + shows toast
|
|
* - Listens for `notification:unread-count` → syncs badge count
|
|
* - Auto-reconnects with exponential backoff (1 s → 10 s)
|
|
* - Disconnects on logout
|
|
*/
|
|
export function useSocketNotifications() {
|
|
const socketRef = useRef<Socket | null>(null);
|
|
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
|
|
const { addNotification, incrementUnread, setUnreadCount } =
|
|
useNotificationsStore();
|
|
|
|
/** Extract the access-token cookie value (if present). */
|
|
const getAccessToken = useCallback((): string | undefined => {
|
|
if (typeof document === 'undefined') return undefined;
|
|
const match = document.cookie
|
|
.split('; ')
|
|
.find((c) => c.startsWith('goodgo_access_token='));
|
|
return match?.split('=')[1];
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (!isAuthenticated) {
|
|
if (socketRef.current) {
|
|
socketRef.current.disconnect();
|
|
socketRef.current = null;
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Don't create duplicate connections
|
|
if (socketRef.current?.connected) return;
|
|
|
|
const token = getAccessToken();
|
|
|
|
const socket = io(`${SOCKET_URL}/notifications`, {
|
|
path: '/socket.io',
|
|
auth: token ? { token } : undefined,
|
|
withCredentials: true, // Also send httpOnly cookies as fallback
|
|
transports: ['websocket', 'polling'],
|
|
reconnection: true,
|
|
reconnectionAttempts: Infinity,
|
|
reconnectionDelay: 1000,
|
|
reconnectionDelayMax: 10000,
|
|
autoConnect: true,
|
|
});
|
|
|
|
socket.on('connect', () => {
|
|
// Connection established — unread count arrives via notification:unread-count
|
|
});
|
|
|
|
socket.on('notification:new', (data: NotificationDto) => {
|
|
addNotification(data);
|
|
incrementUnread();
|
|
|
|
// Show a sonner toast for the incoming notification
|
|
toast(data.title ?? 'Thông báo mới', {
|
|
description: data.body,
|
|
duration: 5000,
|
|
});
|
|
});
|
|
|
|
socket.on(
|
|
'notification:unread-count',
|
|
(data: { unreadCount: number }) => {
|
|
setUnreadCount(data.unreadCount);
|
|
},
|
|
);
|
|
|
|
socket.on('disconnect', (reason) => {
|
|
// Socket.IO auto-reconnects for transport errors.
|
|
// Only server-initiated disconnects need explicit reconnect.
|
|
if (reason === 'io server disconnect') {
|
|
socket.connect();
|
|
}
|
|
});
|
|
|
|
socket.on('connect_error', (err) => {
|
|
console.warn('[ws] connection error:', err.message);
|
|
});
|
|
|
|
socketRef.current = socket;
|
|
|
|
return () => {
|
|
socket.disconnect();
|
|
socketRef.current = null;
|
|
};
|
|
}, [isAuthenticated, addNotification, incrementUnread, setUnreadCount, getAccessToken]);
|
|
}
|