2026-05-05 12:30:00 +00:00
|
|
|
import { useEffect, useRef } from "react";
|
2026-04-21 10:41:01 +00:00
|
|
|
import { io } from "socket.io-client";
|
|
|
|
|
import { useQueryClient } from "@tanstack/react-query";
|
|
|
|
|
import {
|
|
|
|
|
getListNotificationsQueryKey,
|
|
|
|
|
getGetHomeStatsQueryKey,
|
2026-04-21 18:47:30 +00:00
|
|
|
getListMyServiceOrdersQueryKey,
|
2026-04-21 18:58:19 +00:00
|
|
|
getListIncomingServiceOrdersQueryKey,
|
2026-04-22 10:20:04 +00:00
|
|
|
getListAppsQueryKey,
|
|
|
|
|
getGetMeQueryKey,
|
2026-04-29 13:24:16 +00:00
|
|
|
getListRolesQueryKey,
|
|
|
|
|
getListPermissionsQueryKey,
|
|
|
|
|
getGetRolePermissionsQueryKey,
|
|
|
|
|
getGetRolePermissionAuditQueryKey,
|
|
|
|
|
getGetRoleUsageQueryKey,
|
2026-04-21 10:41:01 +00:00
|
|
|
} from "@workspace/api-client-react";
|
|
|
|
|
import { useAuth } from "@/contexts/AuthContext";
|
2026-05-05 08:13:38 +00:00
|
|
|
import { notificationPlayer } from "@/lib/notification-sounds";
|
2026-05-05 15:01:18 +00:00
|
|
|
import { toast } from "@/hooks/use-toast";
|
|
|
|
|
import i18n from "@/i18n";
|
2026-05-05 15:39:39 +00:00
|
|
|
import {
|
|
|
|
|
useIncomingNotePopup,
|
|
|
|
|
type IncomingNotePayload,
|
2026-05-05 21:32:37 +00:00
|
|
|
type IncomingReplyPayload,
|
2026-05-05 15:39:39 +00:00
|
|
|
} from "@/contexts/IncomingNotePopupContext";
|
2026-04-21 10:41:01 +00:00
|
|
|
|
|
|
|
|
const BASE = import.meta.env.BASE_URL.replace(/\/$/, "");
|
|
|
|
|
|
2026-05-24 08:33:53 +00:00
|
|
|
// #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;
|
2026-05-05 12:30:00 +00:00
|
|
|
const nowMs = () =>
|
|
|
|
|
typeof performance !== "undefined" ? performance.now() : Date.now();
|
|
|
|
|
|
2026-04-21 10:41:01 +00:00
|
|
|
export function useNotificationsSocket() {
|
|
|
|
|
const { user } = useAuth();
|
|
|
|
|
const queryClient = useQueryClient();
|
2026-05-05 12:30:00 +00:00
|
|
|
const connectedAtRef = useRef<number>(0);
|
2026-05-07 11:24:57 +00:00
|
|
|
const { enqueue: enqueueNotePopup, updateChecklistItems } =
|
|
|
|
|
useIncomingNotePopup();
|
2026-05-05 15:39:39 +00:00
|
|
|
const enqueueNotePopupRef = useRef(enqueueNotePopup);
|
|
|
|
|
enqueueNotePopupRef.current = enqueueNotePopup;
|
2026-05-07 11:24:57 +00:00
|
|
|
const updateChecklistItemsRef = useRef(updateChecklistItems);
|
|
|
|
|
updateChecklistItemsRef.current = updateChecklistItems;
|
2026-05-05 15:39:39 +00:00
|
|
|
const currentUserIdRef = useRef<number | null>(user?.id ?? null);
|
|
|
|
|
currentUserIdRef.current = user?.id ?? null;
|
2026-05-05 16:21:14 +00:00
|
|
|
// 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<Set<number>>(new Set());
|
2026-05-05 21:32:37 +00:00
|
|
|
// 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<Set<number>>(new Set());
|
2026-04-21 10:41:01 +00:00
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (!user) return;
|
|
|
|
|
|
2026-05-05 12:30:00 +00:00
|
|
|
// 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();
|
|
|
|
|
|
2026-05-06 11:54:13 +00:00
|
|
|
// ---- 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<string, readonly unknown[]>();
|
|
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2026-04-21 10:41:01 +00:00
|
|
|
const socket = io(window.location.origin, {
|
|
|
|
|
path: `${BASE}/api/socket.io`,
|
2026-05-24 10:29:04 +00:00
|
|
|
// #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,
|
2026-04-21 10:41:01 +00:00
|
|
|
});
|
|
|
|
|
|
2026-05-24 10:29:04 +00:00
|
|
|
// #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());
|
|
|
|
|
};
|
|
|
|
|
|
2026-05-05 12:30:00 +00:00
|
|
|
socket.on("connect", () => {
|
2026-05-24 10:29:04 +00:00
|
|
|
// Reset the warmup window so a flush of pending notifications
|
|
|
|
|
// delivered immediately after reconnect doesn't chime.
|
2026-05-05 12:30:00 +00:00
|
|
|
connectedAtRef.current = nowMs();
|
2026-05-24 10:29:04 +00:00
|
|
|
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);
|
2026-05-05 12:30:00 +00:00
|
|
|
});
|
|
|
|
|
|
2026-04-21 18:47:30 +00:00
|
|
|
socket.on("notification_created", (payload?: { type?: string }) => {
|
2026-05-06 11:54:13 +00:00
|
|
|
invalidate(getListNotificationsQueryKey());
|
|
|
|
|
invalidate(getGetHomeStatsQueryKey());
|
2026-04-21 18:47:30 +00:00
|
|
|
if (payload?.type === "order") {
|
2026-05-06 11:54:13 +00:00
|
|
|
invalidate(getListMyServiceOrdersQueryKey(),);
|
|
|
|
|
invalidate(getListIncomingServiceOrdersQueryKey(),);
|
2026-04-21 18:47:30 +00:00
|
|
|
}
|
2026-05-05 08:13:38 +00:00
|
|
|
|
2026-05-05 08:31:27 +00:00
|
|
|
// 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.
|
2026-05-05 08:13:38 +00:00
|
|
|
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;
|
2026-05-05 12:30:00 +00:00
|
|
|
// 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;
|
2026-05-05 08:13:38 +00:00
|
|
|
const soundId = isOrder
|
|
|
|
|
? user.notificationSoundOrder
|
|
|
|
|
: user.notificationSoundMeeting;
|
2026-05-05 08:20:54 +00:00
|
|
|
const vibrate = isOrder
|
|
|
|
|
? user.vibrationEnabledOrder
|
|
|
|
|
: user.vibrationEnabledMeeting;
|
2026-05-24 08:33:53 +00:00
|
|
|
// #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",
|
|
|
|
|
});
|
2026-04-21 18:47:30 +00:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
socket.on("order_updated", () => {
|
2026-05-06 11:54:13 +00:00
|
|
|
invalidate(getListMyServiceOrdersQueryKey(),);
|
|
|
|
|
invalidate(getListIncomingServiceOrdersQueryKey(),);
|
2026-04-21 18:58:19 +00:00
|
|
|
});
|
|
|
|
|
|
2026-04-22 06:37:01 +00:00
|
|
|
socket.on("order_deleted", () => {
|
2026-05-06 11:54:13 +00:00
|
|
|
invalidate(getListMyServiceOrdersQueryKey(),);
|
|
|
|
|
invalidate(getListIncomingServiceOrdersQueryKey(),);
|
2026-04-22 06:37:01 +00:00
|
|
|
});
|
|
|
|
|
|
2026-04-21 18:58:19 +00:00
|
|
|
socket.on("order_incoming_changed", () => {
|
2026-05-06 11:54:13 +00:00
|
|
|
invalidate(getListIncomingServiceOrdersQueryKey(),);
|
2026-04-21 10:41:01 +00:00
|
|
|
});
|
|
|
|
|
|
2026-05-05 15:01:18 +00:00
|
|
|
// 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.
|
2026-05-05 15:39:39 +00:00
|
|
|
socket.on("note_received", (payload?: Partial<IncomingNotePayload>) => {
|
2026-05-06 11:54:13 +00:00
|
|
|
invalidate(["notes"]);
|
2026-05-05 15:39:39 +00:00
|
|
|
const inWarmup = nowMs() - connectedAtRef.current < SOCKET_WARMUP_MS;
|
2026-05-05 16:21:14 +00:00
|
|
|
let enqueued = false;
|
2026-05-05 15:39:39 +00:00
|
|
|
if (
|
|
|
|
|
payload &&
|
|
|
|
|
typeof payload.noteId === "number" &&
|
|
|
|
|
typeof payload.senderUserId === "number"
|
|
|
|
|
) {
|
2026-05-07 09:23:29 +00:00
|
|
|
const rawKind = (payload as { kind?: unknown }).kind;
|
2026-05-07 09:32:17 +00:00
|
|
|
// 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";
|
2026-05-07 09:23:29 +00:00
|
|
|
const rawItems = (payload as { items?: unknown }).items;
|
|
|
|
|
const items = Array.isArray(rawItems)
|
|
|
|
|
? (rawItems as IncomingNotePayload["items"])
|
|
|
|
|
: null;
|
2026-05-05 15:39:39 +00:00
|
|
|
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,
|
2026-05-07 09:23:29 +00:00
|
|
|
noteKind,
|
|
|
|
|
items,
|
2026-05-05 15:39:39 +00:00
|
|
|
};
|
2026-05-05 16:21:14 +00:00
|
|
|
enqueued = enqueueNotePopupRef.current(
|
|
|
|
|
full,
|
|
|
|
|
currentUserIdRef.current,
|
|
|
|
|
);
|
2026-05-05 15:39:39 +00:00
|
|
|
}
|
|
|
|
|
if (inWarmup) return;
|
2026-05-05 16:21:14 +00:00
|
|
|
// 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,
|
2026-05-24 08:33:53 +00:00
|
|
|
throttleBucket: "note",
|
2026-05-05 16:21:14 +00:00
|
|
|
});
|
|
|
|
|
}
|
2026-05-06 11:43:23 +00:00
|
|
|
// 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"),
|
|
|
|
|
});
|
|
|
|
|
}
|
2026-05-05 14:53:30 +00:00
|
|
|
});
|
|
|
|
|
|
2026-05-05 21:32:37 +00:00
|
|
|
socket.on(
|
|
|
|
|
"note_replied",
|
|
|
|
|
(payload?: Partial<IncomingReplyPayload> & { recipientUserId?: number }) => {
|
2026-05-06 11:54:13 +00:00
|
|
|
invalidate(["notes"]);
|
2026-05-05 21:32:37 +00:00
|
|
|
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,
|
2026-05-24 08:33:53 +00:00
|
|
|
throttleBucket: "note",
|
2026-05-05 21:32:37 +00:00
|
|
|
});
|
|
|
|
|
}
|
2026-05-06 11:43:23 +00:00
|
|
|
// 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"),
|
|
|
|
|
});
|
|
|
|
|
}
|
2026-05-05 21:32:37 +00:00
|
|
|
},
|
|
|
|
|
);
|
2026-05-05 14:53:30 +00:00
|
|
|
|
|
|
|
|
socket.on("note_status_changed", () => {
|
2026-05-06 11:54:13 +00:00
|
|
|
invalidate(["notes"]);
|
2026-05-05 14:53:30 +00:00
|
|
|
});
|
|
|
|
|
|
2026-05-14 06:23:49 +00:00
|
|
|
// a collaborator (owner or another recipient) toggled a
|
2026-05-07 11:24:57 +00:00
|
|
|
// 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);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
|
2026-05-09 11:07:30 +00:00
|
|
|
// 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]);
|
|
|
|
|
}
|
2026-05-14 06:23:49 +00:00
|
|
|
// this event also fanouts to the OWNER + every other
|
2026-05-10 10:16:05 +00:00
|
|
|
// 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"]);
|
2026-05-09 11:07:30 +00:00
|
|
|
},
|
|
|
|
|
);
|
2026-05-14 06:23:49 +00:00
|
|
|
// dedicated event for permission-only flips on an
|
2026-05-10 11:11:45 +00:00
|
|
|
// 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]);
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
);
|
2026-05-09 11:07:30 +00:00
|
|
|
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]);
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
|
2026-04-22 10:20:04 +00:00
|
|
|
socket.on("apps_changed", () => {
|
2026-05-06 11:54:13 +00:00
|
|
|
invalidate(getListAppsQueryKey());
|
|
|
|
|
invalidate(getGetMeQueryKey());
|
2026-04-22 10:20:04 +00:00
|
|
|
});
|
|
|
|
|
|
2026-04-29 14:13:07 +00:00
|
|
|
socket.on(
|
|
|
|
|
"executive_meetings_changed",
|
|
|
|
|
(payload?: { date?: string }) => {
|
|
|
|
|
if (typeof payload?.date === "string" && payload.date.length > 0) {
|
2026-05-06 11:54:13 +00:00
|
|
|
invalidate(["/api/executive-meetings", payload.date],);
|
2026-04-29 14:13:07 +00:00
|
|
|
} else {
|
2026-05-06 11:54:13 +00:00
|
|
|
invalidate(["/api/executive-meetings"],);
|
2026-04-29 14:13:07 +00:00
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
|
2026-04-29 17:22:20 +00:00
|
|
|
socket.on("executive_meeting_notifications_changed", () => {
|
2026-05-06 11:54:13 +00:00
|
|
|
invalidate(["/api/executive-meetings/notifications"],);
|
2026-04-29 17:22:20 +00:00
|
|
|
});
|
|
|
|
|
|
2026-05-01 14:23:01 +00:00
|
|
|
// #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",
|
|
|
|
|
() => {
|
2026-05-06 11:54:13 +00:00
|
|
|
invalidate(["/api/executive-meetings/alert-state"],);
|
2026-05-01 14:23:01 +00:00
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
|
2026-04-29 13:24:16 +00:00
|
|
|
socket.on(
|
|
|
|
|
"role_permissions_changed",
|
|
|
|
|
(payload?: { roleId?: number }) => {
|
2026-05-06 11:54:13 +00:00
|
|
|
invalidate(getGetMeQueryKey());
|
|
|
|
|
invalidate(getListPermissionsQueryKey());
|
|
|
|
|
invalidate(getListRolesQueryKey());
|
2026-04-29 13:24:16 +00:00
|
|
|
const roleId = payload?.roleId;
|
|
|
|
|
if (typeof roleId === "number") {
|
2026-05-06 11:54:13 +00:00
|
|
|
invalidate(getGetRolePermissionsQueryKey(roleId),);
|
|
|
|
|
invalidate(getGetRolePermissionAuditQueryKey(roleId),);
|
|
|
|
|
invalidate(getGetRoleUsageQueryKey(roleId),);
|
2026-04-29 13:24:16 +00:00
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
|
2026-05-24 10:29:04 +00:00
|
|
|
// #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);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-21 10:41:01 +00:00
|
|
|
return () => {
|
2026-05-24 10:29:04 +00:00
|
|
|
if (typeof document !== "undefined") {
|
|
|
|
|
document.removeEventListener("visibilitychange", onVisibility);
|
|
|
|
|
}
|
2026-05-06 11:54:13 +00:00
|
|
|
if (rafHandle !== null) {
|
|
|
|
|
if (typeof window !== "undefined" && typeof window.cancelAnimationFrame === "function") {
|
|
|
|
|
window.cancelAnimationFrame(rafHandle);
|
|
|
|
|
} else {
|
|
|
|
|
window.clearTimeout(rafHandle);
|
|
|
|
|
}
|
|
|
|
|
rafHandle = null;
|
|
|
|
|
}
|
|
|
|
|
pendingKeys.clear();
|
2026-04-21 10:41:01 +00:00
|
|
|
socket.disconnect();
|
|
|
|
|
};
|
|
|
|
|
}, [user, queryClient]);
|
|
|
|
|
}
|