Files
IdP/apps/frontend/components/notifications/realtime-provider.tsx
2026-06-30 10:36:21 +03:00

159 lines
5.0 KiB
TypeScript

'use client';
import * as React from 'react';
import { useAuth } from '@/components/id/auth-provider';
import { ensureApiGatewayReady, fetchUnreadNotificationCount, 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<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, isLoading, isContentReady } = 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 || isLoading || !isContentReady) {
setUnreadCount(0);
return;
}
let cancelled = false;
const timer = window.setTimeout(() => {
void fetchUnreadNotificationCount(token)
.then((result) => {
if (!cancelled) setUnreadCount(result.count ?? 0);
})
.catch(() => undefined);
}, 400);
return () => {
cancelled = true;
window.clearTimeout(timer);
};
}, [isContentReady, isLoading, isPinLocked, token, user]);
React.useEffect(() => {
if (!user || !token || isPinLocked || isLoading || !isContentReady) {
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 === 'login_success' ||
(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, isContentReady, isLoading, 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;
}