All files / src/hooks useRealtimeNotifications.ts

30.43% Statements 56/184
100% Branches 0/0
0% Functions 0/1
30.43% Lines 56/184

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 1851x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x                                                                                                                                                                                                                                                                 1x 1x  
'use client';
 
/**
 * useRealtimeNotifications Hook
 *
 * Handles real-time notification updates via WebSocket.
 * Integrates with Redux for state management and shows browser notifications.
 */
 
import { useEffect, useCallback } from 'react';
import { useSocket } from './useSocket';
import { useAppDispatch, useAppSelector } from '@/redux/store';
import {
  addNotification,
  markAsReadLocal,
  selectUnreadCount,
  selectNotifications,
} from '@/redux/features/notificationsSlice';
import type { NotificationPayload } from '@/lib/notifications/types';
 
export type ConnectionMode = 'realtime' | 'disconnected';
 
export interface UseRealtimeNotificationsReturn {
  /** Whether connected to real-time server */
  isConnected: boolean;
  /** Current unread count from Redux */
  unreadCount: number;
  /** All notifications from Redux */
  notifications: NotificationPayload[];
  /** Mark notifications as read via socket (for cross-device sync) */
  markAsRead: (ids: string[]) => void;
  /** Connection mode indicator */
  mode: ConnectionMode;
  /** Reconnection attempts count */
  reconnectAttempts: number;
  /** Connection error if any */
  error: Error | null;
}
 
export interface UseRealtimeNotificationsOptions {
  /** Whether to show browser notifications (default: true) */
  showBrowserNotifications?: boolean;
  /** Whether to play sound on new notification (default: false) */
  playSound?: boolean;
  /** Custom sound URL for notifications */
  soundUrl?: string;
}
 
const DEFAULT_OPTIONS: UseRealtimeNotificationsOptions = {
  showBrowserNotifications: true,
  playSound: false,
  soundUrl: '/sounds/notification.mp3',
};
 
export function useRealtimeNotifications(
  options: UseRealtimeNotificationsOptions = {}
): UseRealtimeNotificationsReturn {
  const opts = { ...DEFAULT_OPTIONS, ...options };

  const { socket, isConnected, on, error, reconnectAttempts } = useSocket();
  const dispatch = useAppDispatch();

  const unreadCount = useAppSelector(selectUnreadCount);
  const notifications = useAppSelector(selectNotifications);

  /**
   * Request browser notification permission on mount
   */
  useEffect(() => {
    if (opts.showBrowserNotifications && typeof window !== 'undefined' && 'Notification' in window) {
      if (Notification.permission === 'default') {
        Notification.requestPermission();
      }
    }
  }, [opts.showBrowserNotifications]);

  /**
   * Show browser notification
   */
  const showBrowserNotification = useCallback(
    (notification: NotificationPayload) => {
      if (
        opts.showBrowserNotifications &&
        typeof window !== 'undefined' &&
        'Notification' in window &&
        Notification.permission === 'granted'
      ) {
        // Don't show if page is visible
        if (document.visibilityState === 'visible') {
          return;
        }

        const browserNotification = new Notification(notification.title, {
          body: notification.message,
          icon: '/icons/icon-192x192.png',
          badge: '/icons/icon-72x72.png',
          tag: notification.id, // Prevents duplicate notifications
          data: {
            url: notification.link,
            notificationId: notification.id,
          },
        });

        // Handle notification click
        browserNotification.onclick = () => {
          window.focus();
          if (notification.link) {
            window.location.href = notification.link;
          }
          browserNotification.close();
        };

        // Auto-close after 5 seconds
        setTimeout(() => {
          browserNotification.close();
        }, 5000);
      }
    },
    [opts.showBrowserNotifications]
  );

  /**
   * Play notification sound
   */
  const playNotificationSound = useCallback(() => {
    if (opts.playSound && opts.soundUrl && typeof window !== 'undefined') {
      const audio = new Audio(opts.soundUrl);
      audio.volume = 0.5;
      audio.play().catch(() => {
        // Ignore audio play errors (e.g., user hasn't interacted with page yet)
      });
    }
  }, [opts.playSound, opts.soundUrl]);

  /**
   * Listen for real-time notifications
   */
  useEffect(() => {
    if (!isConnected) return;

    const unsubscribe = on('notification', (notification: NotificationPayload) => {
      // Add to Redux store
      dispatch(addNotification(notification));

      // Show browser notification
      showBrowserNotification(notification);

      // Play sound
      playNotificationSound();
    });

    return unsubscribe;
  }, [isConnected, on, dispatch, showBrowserNotification, playNotificationSound]);

  /**
   * Mark notifications as read via socket (for cross-device sync)
   */
  const markAsRead = useCallback(
    (ids: string[]) => {
      // Emit via socket for real-time sync across devices
      if (socket?.connected) {
        socket.emit('markRead', ids);
      }

      // Update local Redux state immediately
      ids.forEach((id) => {
        dispatch(markAsReadLocal(id));
      });
    },
    [socket, dispatch]
  );

  return {
    isConnected,
    unreadCount,
    notifications,
    markAsRead,
    mode: isConnected ? 'realtime' : 'disconnected',
    reconnectAttempts,
    error,
  };
}
 
export default useRealtimeNotifications;