import { useQueryClient } from "@tanstack/react-query"; import { useEffect, useRef } from "react"; import { matchAffectedKeys, qk } from "../api/queryKeys"; import { useMe } from "./auth"; interface ServerEvent { type: "match" | "match_draft" | "group" | "invitations" | "notifications"; match_id?: number; group_id?: number; /** Кто играл в партии: их история и профили протухли, чужие — нет. */ participant_ids?: number[]; } /** * Подписка на SSE-поток `/api/events` (push-to-invalidate): по событию с сервера точечно * инвалидируем нужные запросы, и TanStack Query сам их перезапрашивает. EventSource * переподключается автоматически. Открываем одно соединение, только когда авторизованы. */ export function useServerEvents(enabled: boolean) { const qc = useQueryClient(); const { data: me } = useMe(); // Свой id — в ref: положив его в зависимости эффекта, мы бы пересоздавали // SSE-соединение каждый раз, когда профиль перезапрашивается. const myId = useRef(null); myId.current = me?.id ?? null; useEffect(() => { if (!enabled) return; const base = import.meta.env.VITE_API_BASE_URL || ""; const es = new EventSource(`${base}/api/events`, { withCredentials: true }); es.onmessage = (e) => { let ev: ServerEvent; try { ev = JSON.parse(e.data) as ServerEvent; } catch { return; } if (ev.type === "invitations") { qc.invalidateQueries({ queryKey: qk.invitations }); } else if (ev.type === "notifications") { qc.invalidateQueries({ queryKey: qk.notifications }); } else if (ev.type === "match_draft") { // Черновик меняется на каждое движение тайла: трогаем только саму партию, // без лидерборда и историй — иначе группа захлебнётся перезапросами. if (ev.match_id != null) qc.invalidateQueries({ queryKey: qk.match(ev.match_id) }); } else if (ev.type === "match") { if (ev.match_id != null) qc.invalidateQueries({ queryKey: qk.match(ev.match_id) }); if (ev.group_id != null) { qc.invalidateQueries({ queryKey: qk.groupMatches(ev.group_id) }); qc.invalidateQueries({ queryKey: qk.groupStats(ev.group_id) }); } // Общее меняется от любой партии: рейтинг глобальный, и чужая игра двигает топ. qc.invalidateQueries({ queryKey: qk.home }); qc.invalidateQueries({ queryKey: qk.leaderboard }); if (ev.participant_ids) { // Личные витрины — только у игравших: иначе каждая партия в группе // заставляла бы всех остальных перезапрашивать свою историю. for (const pid of ev.participant_ids) { qc.invalidateQueries({ queryKey: qk.userMatches(pid) }); qc.invalidateQueries({ queryKey: qk.publicProfile(pid) }); } if (myId.current != null && ev.participant_ids.includes(myId.current)) { qc.invalidateQueries({ queryKey: qk.myStats }); } } else { // Событие от бэкенда без списка участников (вкладка открыта до обновления // сервера) — ведём себя как раньше, широко. for (const key of matchAffectedKeys) qc.invalidateQueries({ queryKey: key }); } } else if (ev.type === "group") { if (ev.group_id != null) { qc.invalidateQueries({ queryKey: qk.group(ev.group_id) }); qc.invalidateQueries({ queryKey: qk.groupMembers(ev.group_id) }); qc.invalidateQueries({ queryKey: qk.groupStats(ev.group_id) }); } qc.invalidateQueries({ queryKey: qk.me }); qc.invalidateQueries({ queryKey: qk.home }); qc.invalidateQueries({ queryKey: qk.invitations }); } }; // onerror не логируем: EventSource переподключается сам. return () => es.close(); }, [enabled, qc]); }