Files
IdP/apps/frontend/components/notifications/realtime-provider.tsx
2026-06-25 15:14:50 +03:00

116 lines
3.3 KiB
TypeScript

'use client';
import * as React from 'react';
import { useAuth } from '@/components/id/auth-provider';
import { getWsUrl, type ChatMessage } from '@/lib/api';
export interface RealtimeEvent {
userId?: string;
type: string;
title: string;
message: string;
payload?: Record<string, unknown> & {
notificationId?: string;
roomId?: string;
message?: ChatMessage;
inviteId?: string;
groupId?: string;
};
}
type Listener = (event: RealtimeEvent) => void;
interface RealtimeContextValue {
unreadCount: number;
setUnreadCount: React.Dispatch<React.SetStateAction<number>>;
subscribe: (listener: Listener) => () => void;
connected: boolean;
}
const RealtimeContext = React.createContext<RealtimeContextValue | null>(null);
export function RealtimeProvider({ children }: { children: React.ReactNode }) {
const { user, token, isPinLocked } = useAuth();
const [unreadCount, setUnreadCount] = React.useState(0);
const [connected, setConnected] = React.useState(false);
const listenersRef = React.useRef(new Set<Listener>());
const socketRef = React.useRef<WebSocket | null>(null);
const reconnectTimer = React.useRef<ReturnType<typeof setTimeout> | 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) {
socketRef.current?.close();
socketRef.current = null;
setConnected(false);
return;
}
let cancelled = false;
function connect() {
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 === '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
}
};
}
connect();
return () => {
cancelled = true;
if (reconnectTimer.current) clearTimeout(reconnectTimer.current);
socketRef.current?.close();
socketRef.current = null;
setConnected(false);
};
}, [emit, isPinLocked, token, user]);
return (
<RealtimeContext.Provider value={{ unreadCount, setUnreadCount, subscribe, connected }}>
{children}
</RealtimeContext.Provider>
);
}
export function useRealtime() {
const context = React.useContext(RealtimeContext);
if (!context) {
throw new Error('useRealtime должен использоваться внутри RealtimeProvider');
}
return context;
}