import { useEffect, useRef } from "react"; import { io } from "socket.io-client"; import { useQueryClient } from "@tanstack/react-query"; import { getListNotificationsQueryKey, getGetHomeStatsQueryKey, getListMyServiceOrdersQueryKey, getListIncomingServiceOrdersQueryKey, getListAppsQueryKey, getGetMeQueryKey, getListRolesQueryKey, getListPermissionsQueryKey, getGetRolePermissionsQueryKey, getGetRolePermissionAuditQueryKey, getGetRoleUsageQueryKey, } from "@workspace/api-client-react"; import { useAuth } from "@/contexts/AuthContext"; import { notificationPlayer } from "@/lib/notification-sounds"; import { toast } from "@/hooks/use-toast"; import i18n from "@/i18n"; import { useIncomingNotePopup, type IncomingNotePayload, type IncomingReplyPayload, } from "@/contexts/IncomingNotePopupContext"; const BASE = import.meta.env.BASE_URL.replace(/\/$/, ""); const SOCKET_WARMUP_MS = 3000; const nowMs = () => typeof performance !== "undefined" ? performance.now() : Date.now(); export function useNotificationsSocket() { const { user } = useAuth(); const queryClient = useQueryClient(); const connectedAtRef = useRef(0); const { enqueue: enqueueNotePopup } = useIncomingNotePopup(); const enqueueNotePopupRef = useRef(enqueueNotePopup); enqueueNotePopupRef.current = enqueueNotePopup; const currentUserIdRef = useRef(user?.id ?? null); currentUserIdRef.current = user?.id ?? null; // Dedupe note chimes by noteId so a socket reconnect that replays the // same `note_received` event (or a duplicate emit from the server) // doesn't beep twice for the same incoming note. Mirrors the // `playedRef` pattern used by upcoming-meeting-alert. const playedNoteIdsRef = useRef>(new Set()); // Same dedupe shape as `playedNoteIdsRef`, but keyed on replyId so a // reconnect that replays a `note_replied` event doesn't re-chime. const playedReplyIdsRef = useRef>(new Set()); useEffect(() => { if (!user) return; // Suppress chimes for the first few seconds after the socket // connects so any pending notifications that flush in on page // load don't all play sounds back-to-back. connectedAtRef.current = nowMs(); const socket = io(window.location.origin, { path: `${BASE}/api/socket.io`, }); socket.on("connect", () => { connectedAtRef.current = nowMs(); }); socket.on("notification_created", (payload?: { type?: string }) => { queryClient.invalidateQueries({ queryKey: getListNotificationsQueryKey() }); queryClient.invalidateQueries({ queryKey: getGetHomeStatsQueryKey() }); if (payload?.type === "order") { queryClient.invalidateQueries({ queryKey: getListMyServiceOrdersQueryKey(), }); queryClient.invalidateQueries({ queryKey: getListIncomingServiceOrdersQueryKey(), }); } // Per-user sound + vibration. Plays whether the tab is in the // foreground or background — users want to hear the chime even // while looking at the app. Still silenced by global mute and // per-channel opt-outs below. if (user.notificationsMuted) return; const isOrder = payload?.type === "order"; const isMeeting = payload?.type === "executive_meeting"; if (isOrder && !user.notifyOrdersEnabled) return; if (isMeeting && !user.notifyMeetingsEnabled) return; if (!isOrder && !isMeeting) return; // Skip audio/vibration during the warmup window — UI/badge // counts (above) still update, but we stay silent so a // page-load flush doesn't sound like an alarm. if (nowMs() - connectedAtRef.current < SOCKET_WARMUP_MS) return; const soundId = isOrder ? user.notificationSoundOrder : user.notificationSoundMeeting; const vibrate = isOrder ? user.vibrationEnabledOrder : user.vibrationEnabledMeeting; notificationPlayer.play(soundId, { vibrate }); }); socket.on("order_updated", () => { queryClient.invalidateQueries({ queryKey: getListMyServiceOrdersQueryKey(), }); queryClient.invalidateQueries({ queryKey: getListIncomingServiceOrdersQueryKey(), }); }); socket.on("order_deleted", () => { queryClient.invalidateQueries({ queryKey: getListMyServiceOrdersQueryKey(), }); queryClient.invalidateQueries({ queryKey: getListIncomingServiceOrdersQueryKey(), }); }); socket.on("order_incoming_changed", () => { queryClient.invalidateQueries({ queryKey: getListIncomingServiceOrdersQueryKey(), }); }); // Realtime note events: invalidate notes queries AND surface a toast so // the recipient (or sender, on a reply) sees an immediate, dismissible // confirmation regardless of which page they're on. socket.on("note_received", (payload?: Partial) => { queryClient.invalidateQueries({ queryKey: ["notes"] }); const inWarmup = nowMs() - connectedAtRef.current < SOCKET_WARMUP_MS; let enqueued = false; if ( payload && typeof payload.noteId === "number" && typeof payload.senderUserId === "number" ) { const full: IncomingNotePayload = { noteId: payload.noteId, recipientRowId: payload.recipientRowId, title: typeof payload.title === "string" ? payload.title : "", content: typeof payload.content === "string" ? payload.content : "", color: typeof payload.color === "string" ? payload.color : "default", sentAt: payload.sentAt, senderUserId: payload.senderUserId, sender: payload.sender ?? null, }; enqueued = enqueueNotePopupRef.current( full, currentUserIdRef.current, ); } if (inWarmup) return; // Sound + vibration for incoming notes — gated by global mute and // the per-channel notes toggle. Deduped by noteId so a socket // reconnect that replays the same event doesn't re-chime. Only // plays when the popup actually enqueued (i.e. recipient !== sender). if ( enqueued && payload && typeof payload.noteId === "number" && !user.notificationsMuted && user.notifyNotesEnabled && !playedNoteIdsRef.current.has(payload.noteId) ) { playedNoteIdsRef.current.add(payload.noteId); notificationPlayer.play(user.notificationSoundNote, { vibrate: user.vibrationEnabledNote, }); } toast({ title: i18n.t("notes.toast.received.title"), description: i18n.t("notes.toast.received.desc"), }); }); socket.on( "note_replied", (payload?: Partial & { recipientUserId?: number }) => { queryClient.invalidateQueries({ queryKey: ["notes"] }); const inWarmup = nowMs() - connectedAtRef.current < SOCKET_WARMUP_MS; let enqueued = false; // The server emits `note_replied` only to the *original note // owner* (i.e. the sender). Build a reply popup payload — the // queue dedupes on replyId so a socket reconnect that replays // the same event won't re-show or re-chime. if ( payload && typeof payload.noteId === "number" && typeof payload.replyId === "number" ) { // The "sender" of the *original* note is the current user // (the owner) — we don't have their full UserSummary on the // payload, but the floating card never renders it for the // reply variant; it shows the replier instead. Use the // replier's id as a stand-in for senderUserId so the // queue's "ignore my own outgoing" guard treats this event // as inbound (the replier is *not* the current user). const replierId = payload.replier?.id; const senderUserId = typeof replierId === "number" ? replierId : -1; const full: IncomingReplyPayload = { kind: "reply", noteId: payload.noteId, replyId: payload.replyId, replyContent: typeof payload.replyContent === "string" ? payload.replyContent : "", replier: payload.replier ?? null, noteTitle: typeof payload.noteTitle === "string" ? payload.noteTitle : "", title: typeof payload.noteTitle === "string" ? payload.noteTitle : "", content: typeof payload.replyContent === "string" ? payload.replyContent : "", color: typeof payload.color === "string" ? payload.color : "default", senderUserId, sender: payload.replier ?? null, }; enqueued = enqueueNotePopupRef.current( full, currentUserIdRef.current, ); } if (inWarmup) return; // Sound + vibration mirror the new-note alert. Reuse the same // notes channel prefs (notificationSoundNote / // vibrationEnabledNote / notifyNotesEnabled) — the user // confirmed they want a single sensory profile for both // events. Dedupe by replyId via a per-session set. const replyId = payload?.replyId; if ( enqueued && typeof replyId === "number" && !user.notificationsMuted && user.notifyNotesEnabled && !playedReplyIdsRef.current.has(replyId) ) { playedReplyIdsRef.current.add(replyId); notificationPlayer.play(user.notificationSoundNote, { vibrate: user.vibrationEnabledNote, }); } toast({ title: i18n.t("notes.toast.replied.title"), description: i18n.t("notes.toast.replied.desc"), }); }, ); socket.on("note_status_changed", () => { queryClient.invalidateQueries({ queryKey: ["notes"] }); }); socket.on("apps_changed", () => { queryClient.invalidateQueries({ queryKey: getListAppsQueryKey() }); queryClient.invalidateQueries({ queryKey: getGetMeQueryKey() }); }); socket.on( "executive_meetings_changed", (payload?: { date?: string }) => { if (typeof payload?.date === "string" && payload.date.length > 0) { queryClient.invalidateQueries({ queryKey: ["/api/executive-meetings", payload.date], }); } else { queryClient.invalidateQueries({ queryKey: ["/api/executive-meetings"], }); } }, ); socket.on("executive_meeting_notifications_changed", () => { queryClient.invalidateQueries({ queryKey: ["/api/executive-meetings/notifications"], }); }); // #277: Per-user push for the 5-min upcoming-meeting alert. The server // only emits this to `user:${userId}`, so receiving it means *this* // user (in this or another of their tabs / devices) acknowledged or // dismissed an alert. Invalidate the alert-state query so the open // alert card hides within ~1s instead of waiting on the 30s poll. // Other users do not receive this event, so their alert stays visible. socket.on( "executive_meeting_alert_state_changed", () => { queryClient.invalidateQueries({ queryKey: ["/api/executive-meetings/alert-state"], }); }, ); socket.on( "role_permissions_changed", (payload?: { roleId?: number }) => { queryClient.invalidateQueries({ queryKey: getGetMeQueryKey() }); queryClient.invalidateQueries({ queryKey: getListPermissionsQueryKey() }); queryClient.invalidateQueries({ queryKey: getListRolesQueryKey() }); const roleId = payload?.roleId; if (typeof roleId === "number") { queryClient.invalidateQueries({ queryKey: getGetRolePermissionsQueryKey(roleId), }); queryClient.invalidateQueries({ queryKey: getGetRolePermissionAuditQueryKey(roleId), }); queryClient.invalidateQueries({ queryKey: getGetRoleUsageQueryKey(roleId), }); } }, ); return () => { socket.disconnect(); }; }, [user, queryClient]); }