168 lines
5.4 KiB
TypeScript
168 lines
5.4 KiB
TypeScript
'use client';
|
|
|
|
import * as React from 'react';
|
|
import { useAuth } from '@/components/id/auth-provider';
|
|
import { fetchUnreadNotificationCount, getWsUrl, type ChatMessage } from '@/lib/api';
|
|
import { dispatchAvatarUpdated, dispatchChatRoomAvatarUpdated } from '@/lib/avatar-events';
|
|
|
|
const WS_RECONNECT_BASE_MS = 2_000;
|
|
const WS_RECONNECT_MAX_MS = 30_000;
|
|
|
|
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 reconnectAttemptRef = React.useRef(0);
|
|
|
|
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;
|
|
|
|
function scheduleReconnect() {
|
|
if (cancelled) return;
|
|
if (reconnectTimer.current) clearTimeout(reconnectTimer.current);
|
|
const attempt = reconnectAttemptRef.current;
|
|
const delay = Math.min(WS_RECONNECT_MAX_MS, WS_RECONNECT_BASE_MS * 2 ** Math.min(attempt, 4));
|
|
reconnectAttemptRef.current = attempt + 1;
|
|
reconnectTimer.current = setTimeout(connect, delay);
|
|
}
|
|
|
|
async function connect() {
|
|
if (cancelled) return;
|
|
const url = `${getWsUrl()}?token=${encodeURIComponent(token!)}`;
|
|
const socket = new WebSocket(url);
|
|
socketRef.current = socket;
|
|
|
|
socket.onopen = () => {
|
|
reconnectAttemptRef.current = 0;
|
|
setConnected(true);
|
|
};
|
|
socket.onclose = () => {
|
|
setConnected(false);
|
|
scheduleReconnect();
|
|
};
|
|
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);
|
|
reconnectAttemptRef.current = 0;
|
|
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;
|
|
}
|