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, updateChecklistItems } = useIncomingNotePopup(); const enqueueNotePopupRef = useRef(enqueueNotePopup); enqueueNotePopupRef.current = enqueueNotePopup; const updateChecklistItemsRef = useRef(updateChecklistItems); updateChecklistItemsRef.current = updateChecklistItems; 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(); // ---- Coalesced invalidations (#427) ---- // When several socket events arrive in the same JS task (e.g. a // `note_received` AND a `notification_created` AND a meeting // alert all fire from one server fanout), invalidating each // query key synchronously triggers a re-render storm that // briefly stalls the UI on slower iPad/phone hardware — which // contributed to the perceived "tap hang". We collect query // keys in a Set keyed by JSON stringification (cheap dedupe) // and flush them once per animation frame so React Query batches // the refetches into one render pass. const pendingKeys = new Map(); let rafHandle: number | null = null; const flushPending = () => { rafHandle = null; const keys = Array.from(pendingKeys.values()); pendingKeys.clear(); for (const key of keys) { queryClient.invalidateQueries({ queryKey: key as unknown[] }); } }; const invalidate = (key: readonly unknown[]) => { pendingKeys.set(JSON.stringify(key), key); if (rafHandle !== null) return; if (typeof window !== "undefined" && typeof window.requestAnimationFrame === "function") { rafHandle = window.requestAnimationFrame(flushPending); } else { // SSR / test fallback: macro-task defer is still enough to // coalesce events arriving in the same tick. rafHandle = window.setTimeout(flushPending, 0); } }; const socket = io(window.location.origin, { path: `${BASE}/api/socket.io`, }); socket.on("connect", () => { connectedAtRef.current = nowMs(); }); socket.on("notification_created", (payload?: { type?: string }) => { invalidate(getListNotificationsQueryKey()); invalidate(getGetHomeStatsQueryKey()); if (payload?.type === "order") { invalidate(getListMyServiceOrdersQueryKey(),); invalidate(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", () => { invalidate(getListMyServiceOrdersQueryKey(),); invalidate(getListIncomingServiceOrdersQueryKey(),); }); socket.on("order_deleted", () => { invalidate(getListMyServiceOrdersQueryKey(),); invalidate(getListIncomingServiceOrdersQueryKey(),); }); socket.on("order_incoming_changed", () => { invalidate(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) => { invalidate(["notes"]); const inWarmup = nowMs() - connectedAtRef.current < SOCKET_WARMUP_MS; let enqueued = false; if ( payload && typeof payload.noteId === "number" && typeof payload.senderUserId === "number" ) { const rawKind = (payload as { kind?: unknown }).kind; // Default missing/unknown kind to "text" — the legacy server // emit (pre-checklist) didn't include `kind`, and the popup // body's text branch is the safe fallback. const noteKind: "text" | "checklist" = rawKind === "checklist" ? "checklist" : "text"; const rawItems = (payload as { items?: unknown }).items; const items = Array.isArray(rawItems) ? (rawItems as IncomingNotePayload["items"]) : null; 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, noteKind, items, }; 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, }); } // Only fall back to a toast when the floating popup did NOT // accept this event (sender's own note, duplicate, malformed, // etc.). When the popup IS shown, the toast is redundant — and // on touch devices stacking a toast over an already-stacked // popup + meeting alert produced extra layers that intermittently // intercepted the next tap (#427). if (!enqueued) { toast({ title: i18n.t("notes.toast.received.title"), description: i18n.t("notes.toast.received.desc"), }); } }); socket.on( "note_replied", (payload?: Partial & { recipientUserId?: number }) => { invalidate(["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, }); } // See `note_received` above: skip the redundant toast when the // floating popup is the surface (#427). if (!enqueued) { toast({ title: i18n.t("notes.toast.replied.title"), description: i18n.t("notes.toast.replied.desc"), }); } }, ); socket.on("note_status_changed", () => { invalidate(["notes"]); }); // a collaborator (owner or another recipient) toggled a // checklist item — refetch so this user's open thread / popup / // cards reflect the shared state. Server already updates the // emitter optimistically, so it never receives its own echo. We // also patch any open popup payload directly because the popup // renders from the queued snapshot, not the React Query cache. socket.on( "note_checklist_changed", (payload?: { noteId?: number; items?: { id: string; text: string; done: boolean }[]; }) => { invalidate(["notes"]); if (typeof payload?.noteId === "number") { invalidate(["notes", "thread", payload.noteId]); if (Array.isArray(payload.items)) { updateChecklistItemsRef.current(payload.noteId, payload.items); } } }, ); // Folder-sharing live updates (#445). Owner shares/unshares a folder // with this user → invalidate the rail's "Shared with me" list, and any // open shared-folder note view for that folder so it either refreshes // (still shared) or surfaces the revoked empty state (unshared) without // a manual reload. socket.on( "note-folder-shared", (payload?: { folderId?: number }) => { invalidate(["note-folders", "shared-with-me"]); if (typeof payload?.folderId === "number") { invalidate(["notes", "shared-folder", payload.folderId]); } // this event also fanouts to the OWNER + every other // recipient when an editor mutates the folder's notes (create / // patch / delete / checklist toggle), so refresh the owner's // personal notes list and the folders rail badge counts too. invalidate(["notes"]); invalidate(["note-folders"]); }, ); // dedicated event for permission-only flips on an // existing share (view ↔ edit). Same invalidation as `shared`, // but kept distinct so the contract is explicit and so future UI // can react with a toast like "Your access changed to Edit". socket.on( "note-folder-share-updated", (payload?: { folderId?: number; permission?: "view" | "edit" }) => { invalidate(["note-folders", "shared-with-me"]); if (typeof payload?.folderId === "number") { invalidate(["notes", "shared-folder", payload.folderId]); } }, ); socket.on( "note-folder-unshared", (payload?: { folderId?: number }) => { invalidate(["note-folders", "shared-with-me"]); if (typeof payload?.folderId === "number") { invalidate(["notes", "shared-folder", payload.folderId]); } }, ); socket.on("apps_changed", () => { invalidate(getListAppsQueryKey()); invalidate(getGetMeQueryKey()); }); socket.on( "executive_meetings_changed", (payload?: { date?: string }) => { if (typeof payload?.date === "string" && payload.date.length > 0) { invalidate(["/api/executive-meetings", payload.date],); } else { invalidate(["/api/executive-meetings"],); } }, ); socket.on("executive_meeting_notifications_changed", () => { invalidate(["/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", () => { invalidate(["/api/executive-meetings/alert-state"],); }, ); socket.on( "role_permissions_changed", (payload?: { roleId?: number }) => { invalidate(getGetMeQueryKey()); invalidate(getListPermissionsQueryKey()); invalidate(getListRolesQueryKey()); const roleId = payload?.roleId; if (typeof roleId === "number") { invalidate(getGetRolePermissionsQueryKey(roleId),); invalidate(getGetRolePermissionAuditQueryKey(roleId),); invalidate(getGetRoleUsageQueryKey(roleId),); } }, ); return () => { if (rafHandle !== null) { if (typeof window !== "undefined" && typeof window.cancelAnimationFrame === "function") { window.cancelAnimationFrame(rafHandle); } else { window.clearTimeout(rafHandle); } rafHandle = null; } pendingKeys.clear(); socket.disconnect(); }; }, [user, queryClient]); }