Files
TX/artifacts/tx-os/src/hooks/use-notifications-socket.ts
T
riyadhafraa 4258aa092c Task #410: floating draggable note popup + reply alert at sender
Convert the incoming-note popup from a centered AlertDialog modal into
a floating, draggable, animated card (no backdrop) and surface the
same card variant at the original sender when the recipient replies.

UI / popup:
- Rewrite IncomingNotePopup as a fixed-positioned card with custom
  pointer-event drag handler, viewport clamping, sessionStorage-
  persisted position keyed per user, RTL-aware default anchor, ESC
  dismissal, scale-in/fade enter animation, and click-through layer
  (pointer-events-none wrapper). Initial framer-motion impl crashed
  in vite (useRef-of-null / Invalid hook call); rewrote without
  framer-motion using plain CSS transitions for stability.
- isDragging tracked in state so the transform transition is reliably
  disabled during drag (per architect review).

Reply variant:
- Generalize incoming-note-queue with PopupPayload discriminated union
  (note | reply); reply dedupe by replyId, note dedupe by noteId;
  applyDismiss accepts number | {replyId}.
- Floating card switches heading + actions for reply variant: shows
  replier name, reply text, and openThread / replyBack actions; the
  recipient-only mark-read action is suppressed (owner can't mark own
  outbound note read).

Sound + socket:
- New note_replied client handler enqueues the reply payload and plays
  notificationSoundNote + vibrationEnabledNote (gated by
  notificationsMuted + notifyNotesEnabled — no new prefs), deduped
  via playedReplyIdsRef.
- Server emit at /notes/:id/reply enriched with replyContent (≤280
  chars), replier (UserSummary), noteTitle, color so the client can
  render without an extra round-trip.

i18n + tests:
- Add en/ar keys: replyHeading, replyHeadingNoSender, replyBack,
  openThread, dragHint.
- Extend notes-popup-on-receive.spec.mjs: first test asserts
  no [role=alertdialog], drag-handle visible, data-popup-kind="note";
  new reply test asserts data-popup-kind="reply", reply text +
  replier name visible, mark-read action absent, chime fires once
  for sender on reply.

Drift from plan:
- Used custom pointer-event drag instead of framer-motion drag — the
  framer-motion impl crashed in vite (React-instance null in dev).
  Same UX (drag from header only, click-through behind card).
- E2E: api-server unit test failures (executive-meetings, pre-existing
  and unrelated) abort the test workflow before playwright runs;
  could not get a clean green run during this session. Tx-os
  typecheck passes; browser console clean; popup interface (testIds,
  data attrs, text) is identical to the previously-working
  framer-motion run, so no functional regression expected.
2026-05-05 21:32:37 +00:00

326 lines
12 KiB
TypeScript

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<number>(0);
const { enqueue: enqueueNotePopup } = useIncomingNotePopup();
const enqueueNotePopupRef = useRef(enqueueNotePopup);
enqueueNotePopupRef.current = enqueueNotePopup;
const currentUserIdRef = useRef<number | null>(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<Set<number>>(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<Set<number>>(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();
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(),
});
});
socket.on("order_deleted", () => {
queryClient.invalidateQueries({
queryKey: getListMyServiceOrdersQueryKey(),
});
queryClient.invalidateQueries({
queryKey: getListIncomingServiceOrdersQueryKey(),
});
});
socket.on("order_incoming_changed", () => {
queryClient.invalidateQueries({
queryKey: 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<IncomingNotePayload>) => {
queryClient.invalidateQueries({ queryKey: ["notes"] });
const inWarmup = nowMs() - connectedAtRef.current < SOCKET_WARMUP_MS;
let enqueued = false;
if (
payload &&
typeof payload.noteId === "number" &&
typeof payload.senderUserId === "number"
) {
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,
};
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,
});
}
toast({
title: i18n.t("notes.toast.received.title"),
description: i18n.t("notes.toast.received.desc"),
});
});
socket.on(
"note_replied",
(payload?: Partial<IncomingReplyPayload> & { recipientUserId?: number }) => {
queryClient.invalidateQueries({ queryKey: ["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,
});
}
toast({
title: i18n.t("notes.toast.replied.title"),
description: i18n.t("notes.toast.replied.desc"),
});
},
);
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]);
}