Files
TX/artifacts/tx-os/src/hooks/use-notifications-socket.ts
T

185 lines
6.2 KiB
TypeScript
Raw Normal View History

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";
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<number>(0);
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(),
});
});
2026-04-22 06:37:01 +00:00
socket.on("order_deleted", () => {
queryClient.invalidateQueries({
queryKey: getListMyServiceOrdersQueryKey(),
});
queryClient.invalidateQueries({
queryKey: getListIncomingServiceOrdersQueryKey(),
});
});
socket.on("order_incoming_changed", () => {
queryClient.invalidateQueries({
queryKey: getListIncomingServiceOrdersQueryKey(),
});
});
socket.on("note_received", () => {
queryClient.invalidateQueries({ queryKey: ["notes"] });
});
socket.on("note_replied", () => {
queryClient.invalidateQueries({ queryKey: ["notes"] });
});
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]);
}