'use client'; import * as React from 'react'; import { useAuth } from '@/components/id/auth-provider'; import { ensureApiGatewayReady, getWsUrl, type ChatMessage } from '@/lib/api'; import { dispatchAvatarUpdated, dispatchChatRoomAvatarUpdated } from '@/lib/avatar-events'; export interface RealtimeEvent { userId?: string; type: string; title: string; message: string; payload?: Record & { notificationId?: string; roomId?: string; message?: ChatMessage; inviteId?: string; groupId?: string; }; } type Listener = (event: RealtimeEvent) => void; interface RealtimeContextValue { unreadCount: number; setUnreadCount: React.Dispatch>; subscribe: (listener: Listener) => () => void; connected: boolean; } const RealtimeContext = React.createContext(null); export function RealtimeProvider({ children }: { children: React.ReactNode }) { const { user, token, isPinLocked, isLoading } = useAuth(); const [unreadCount, setUnreadCount] = React.useState(0); const [connected, setConnected] = React.useState(false); const listenersRef = React.useRef(new Set()); const socketRef = React.useRef(null); const reconnectTimer = React.useRef | null>(null); const subscribe = React.useCallback((listener: Listener) => { listenersRef.current.add(listener); return () => listenersRef.current.delete(listener); }, []); const emit = React.useCallback((event: RealtimeEvent) => { listenersRef.current.forEach((listener) => listener(event)); }, []); React.useEffect(() => { if (!user || !token || isPinLocked || isLoading) { socketRef.current?.close(); socketRef.current = null; setConnected(false); return; } let cancelled = false; async function connect() { if (cancelled) return; try { await ensureApiGatewayReady(); } catch { // Пробуем подключиться даже если health не ответил в срок. } if (cancelled) return; const url = `${getWsUrl()}?token=${encodeURIComponent(token!)}`; const socket = new WebSocket(url); socketRef.current = socket; socket.onopen = () => setConnected(true); socket.onclose = () => { setConnected(false); if (!cancelled) { reconnectTimer.current = setTimeout(connect, 3000); } }; socket.onerror = () => socket.close(); socket.onmessage = (event) => { try { const data = JSON.parse(event.data as string) as RealtimeEvent; emit(data); if (data.type === 'user_avatar_updated' && typeof data.payload?.userId === 'string') { dispatchAvatarUpdated(data.payload.userId); } if (data.type === 'chat_message') { const message = data.payload?.message; if (message?.type === 'SYSTEM' && message.roomId && message.metadataJson) { try { const metadata = JSON.parse(message.metadataJson) as { systemKind?: string }; if (metadata.systemKind === 'avatar_changed') { dispatchChatRoomAvatarUpdated(message.roomId); } } catch { // ignore malformed metadata } } } if ( data.type === 'family_invite' || data.type === 'role_assigned' || data.type === 'role_removed' || (data.type === 'chat_message' && data.payload?.notificationId) ) { setUnreadCount((count) => count + 1); } } catch { // ignore malformed payloads } }; } void connect(); return () => { cancelled = true; if (reconnectTimer.current) clearTimeout(reconnectTimer.current); socketRef.current?.close(); socketRef.current = null; setConnected(false); }; }, [emit, isLoading, isPinLocked, token, user]); return ( {children} ); } export function useRealtime() { const context = React.useContext(RealtimeContext); if (!context) { throw new Error('useRealtime должен использоваться внутри RealtimeProvider'); } return context; }