|
|
|
@@ -100,10 +100,70 @@ export function useNotificationsSocket() {
|
|
|
|
|
|
|
|
|
|
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 }) => {
|
|
|
|
@@ -434,7 +494,46 @@ export function useNotificationsSocket() {
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// #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);
|
|
|
|
|