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(/\/$/, ""); // #628: warmup window after socket (re)connect during which chimes // are suppressed so a page-load flush of pending events doesn't sound // like an alarm. Lowered from 3000 → 1000 ms because on mobile the // socket disconnects/reconnects often (background tab, tunnel hop, // PWA wake-up); a 3-second window silently swallowed any order that // arrived in the first few seconds after returning to the app. 1 s // still covers the synchronous initial-burst at first connect. const SOCKET_WARMUP_MS = 1000; 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`, // #632: Behind Tailscale / proxies on iPad PWA the WebSocket can // be silently dropped by NAT/idle timeouts. Tighten the // reconnection budget so the client retries quickly instead of // sitting on a dead connection while orders pile up server-side. reconnection: true, reconnectionDelay: 500, reconnectionDelayMax: 3000, reconnectionAttempts: Infinity, timeout: 10000, }); // #632: Track whether the socket has been disconnected at any // point so the next successful `connect` can refetch every // realtime-driven query — events that fire while the socket was // dead are lost forever otherwise, which is exactly the bug // (recipients seeing orders only after force-quitting the PWA). let wasDisconnected = false; // #632: Replay every event-driven query key on reconnect. Mirrors // the keys touched by individual socket.on handlers below — keep // this list in sync when adding new realtime channels. Role and // permission keys are included so a `role_permissions_changed` // event that fired while the socket was dead is recovered on // reconnect (per architect review). const refetchRealtimeState = () => { invalidate(getListNotificationsQueryKey()); invalidate(getGetHomeStatsQueryKey()); invalidate(getListIncomingServiceOrdersQueryKey()); invalidate(getListMyServiceOrdersQueryKey()); invalidate(["notes"]); invalidate(["note-folders"]); invalidate(["note-folders", "shared-with-me"]); invalidate(["/api/executive-meetings"]); invalidate(["/api/executive-meetings/alert-state"]); invalidate(["/api/executive-meetings/notifications"]); invalidate(getListAppsQueryKey()); invalidate(getGetMeQueryKey()); invalidate(getListRolesQueryKey()); invalidate(getListPermissionsQueryKey()); }; socket.on("connect", () => { // Reset the warmup window so a flush of pending notifications // delivered immediately after reconnect doesn't chime. connectedAtRef.current = nowMs(); if (wasDisconnected) { wasDisconnected = false; refetchRealtimeState(); } }); socket.on("disconnect", (reason) => { wasDisconnected = true; // Surface unexpected disconnects in the console for field // debugging without breaking anything. `io client disconnect` is // the cleanup path on hook teardown — skip the noise for it. if (reason !== "io client disconnect") { // eslint-disable-next-line no-console console.warn("[socket] disconnected:", reason); } }); socket.on("connect_error", (err) => { // eslint-disable-next-line no-console console.warn("[socket] connect_error:", err.message); }); 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; // #628: order chimes use a tighter throttle window so a small // burst of incoming orders still rings for each one. Meeting // chimes keep the default 3s window — a single meeting event // can fan out to several listeners and we don't want a // buzzy double-chime. Buckets keep the two channels isolated // so an order burst can't starve a meeting alert (or vice // versa). notificationPlayer.play(soundId, { vibrate, throttleMs: isOrder ? 800 : 3000, throttleBucket: isOrder ? "order" : "meeting", }); }); 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, throttleBucket: "note", }); } // 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, throttleBucket: "note", }); } // 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),); } }, ); // #632: When the PWA / tab comes back to the foreground, iPad's // background suspension may have silently killed the WebSocket // even though `socket.connected` is still `true` (half-open // socket — the client hasn't sent a ping yet). Probing with a // timed roundtrip is the only reliable way to tell. We use the // socket.io v4 ack-timeout helper to round-trip a cheap event; // if the server doesn't ack within 3s, force-cycle the transport // so missed orders surface within ~3s of returning to the app // instead of waiting on the ping-timeout window (~15s). const onVisibility = () => { if (typeof document === "undefined") return; if (document.visibilityState !== "visible") return; if (!socket.connected) { wasDisconnected = true; socket.connect(); return; } // Half-open probe: round-trip a cheap event through socket.io's // built-in ack channel. The server doesn't need to register a // handler — when no handler exists, the ack still fires with // no args, so a missing ack within 3s means the transport is // dead and we should force-cycle it. socket.timeout(3000).emit( "client_health_probe", (err: Error | null) => { if (err && socket.connected) { wasDisconnected = true; socket.disconnect().connect(); } }, ); }; if (typeof document !== "undefined") { document.addEventListener("visibilitychange", onVisibility); } return () => { if (typeof document !== "undefined") { document.removeEventListener("visibilitychange", onVisibility); } 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]); }