useNotificationWebSocket.ts 3.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107
  1. "use client";
  2. import { useEffect, useRef } from "react";
  3. import { toast } from "sonner";
  4. import { useNotificationStore } from "../store/notificationStore";
  5. import { getNotifications, markNotificationRead } from "../Actions/notificationActions";
  6. import { getActiveSmsCustomerId } from "@/features/sms/activeSmsView";
  7. export function useNotificationWebSocket() {
  8. const wsRef = useRef<WebSocket | null>(null);
  9. const reconnectTimer = useRef<ReturnType<typeof setTimeout>>(undefined);
  10. useEffect(() => {
  11. // Liveness is a per-effect-run closure, not a shared ref: with a ref, a
  12. // remount (StrictMode does this on every mount in dev) let the *old*
  13. // socket's async onclose observe the *new* run's "mounted" state and
  14. // schedule a reconnect — leaving two live sockets delivering every
  15. // notification twice.
  16. let alive = true;
  17. // Fetch initial notifications
  18. getNotifications().then((result) => {
  19. if (!alive) return;
  20. if (result.success && result.data) {
  21. useNotificationStore.getState().setNotifications(
  22. result.data.notifications,
  23. result.data.unreadCount,
  24. );
  25. }
  26. });
  27. function connect() {
  28. if (!alive) return;
  29. const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
  30. const url = `${protocol}//${window.location.host}/api/protected/ws`;
  31. const ws = new WebSocket(url);
  32. wsRef.current = ws;
  33. ws.onopen = () => {
  34. useNotificationStore.getState().setConnected(true);
  35. };
  36. ws.onmessage = (event) => {
  37. try {
  38. const msg = JSON.parse(event.data);
  39. if (msg.type === "notification") {
  40. const data = msg.data;
  41. // If user is already viewing SMS for this customer, auto-read and skip toast
  42. const activeSmsCid = getActiveSmsCustomerId();
  43. if (
  44. activeSmsCid &&
  45. data.type === "sms_inbound" &&
  46. data.entityUrl === `/messages?customerId=${activeSmsCid}`
  47. ) {
  48. // Still add it to the store but immediately mark as read
  49. const added = { ...data, read: true };
  50. useNotificationStore.getState().addNotification(added);
  51. // Decrement the unread count that addNotification just bumped
  52. useNotificationStore.setState((s) => ({
  53. unreadCount: Math.max(0, s.unreadCount - 1),
  54. }));
  55. markNotificationRead(data.id);
  56. return;
  57. }
  58. useNotificationStore.getState().addNotification(data);
  59. const isSms = data.type === "sms_inbound";
  60. toast(data.title, {
  61. description: data.message,
  62. ...(isSms && { duration: 5 * 60 * 1000 }),
  63. action: {
  64. label: "View",
  65. onClick: () => {
  66. window.location.href = data.entityUrl;
  67. },
  68. },
  69. });
  70. }
  71. } catch {
  72. // ignore malformed messages
  73. }
  74. };
  75. ws.onclose = () => {
  76. useNotificationStore.getState().setConnected(false);
  77. wsRef.current = null;
  78. if (alive) {
  79. reconnectTimer.current = setTimeout(connect, 3000);
  80. }
  81. };
  82. ws.onerror = () => {
  83. ws.close();
  84. };
  85. }
  86. connect();
  87. return () => {
  88. alive = false;
  89. clearTimeout(reconnectTimer.current);
  90. wsRef.current?.close();
  91. };
  92. }, []); // no deps — mount once
  93. }