оптимизация веб сокет
This commit is contained in:
parent
0217bc1a38
commit
9462f08825
@ -19,6 +19,7 @@ class NotificationVerbs:
|
||||
MENTION = 'chat_mention'
|
||||
REPLY = 'chat_reply'
|
||||
ADDED_TO_CHAT = 'chat_added_to_chat'
|
||||
COMMENTED = 'commented' # Для комментариев к объектам
|
||||
|
||||
|
||||
class MessageTemplates:
|
||||
|
||||
@ -82,6 +82,10 @@ def notify_new_message(message):
|
||||
2. Ответы на сообщения - средний приоритет
|
||||
3. Обычные сообщения в чате - низкий приоритет
|
||||
|
||||
Специальная обработка для чатов комментариев (type='comments'):
|
||||
- Уведомляет автора объекта (поста/документа/заявки)
|
||||
- Уведомляет других участников дискуссии
|
||||
|
||||
Args:
|
||||
message: Экземпляр модели Message
|
||||
"""
|
||||
@ -89,6 +93,11 @@ def notify_new_message(message):
|
||||
author = message.author
|
||||
content = message.content
|
||||
|
||||
# Специальная обработка для чатов комментариев
|
||||
if chat.type == 'comments':
|
||||
_notify_comment(message)
|
||||
return
|
||||
|
||||
# Получаем всех участников чата кроме автора
|
||||
if chat.type in ['announcement', 'channel', 'global']:
|
||||
participants = chat.get_participants().exclude(id=author.id)
|
||||
@ -202,6 +211,92 @@ def notify_new_message(message):
|
||||
)
|
||||
|
||||
|
||||
def _notify_comment(message):
|
||||
"""
|
||||
Отправляет уведомления о новом комментарии к объекту.
|
||||
|
||||
Уведомляет:
|
||||
1. Автора объекта (Post/Document/Request)
|
||||
2. Других участников обсуждения (кто оставлял комментарии)
|
||||
|
||||
Args:
|
||||
message: Экземпляр модели Message (в чате type='comments')
|
||||
"""
|
||||
chat = message.chat
|
||||
author = message.author
|
||||
content = message.content
|
||||
|
||||
if not chat.context_object:
|
||||
# Нет привязанного объекта - обычная обработка
|
||||
return
|
||||
|
||||
recipients_set = set()
|
||||
context_obj = chat.context_object
|
||||
|
||||
# Определяем автора объекта в зависимости от типа
|
||||
object_author = None
|
||||
object_url = None
|
||||
object_type = "объекта"
|
||||
|
||||
# Проверяем тип объекта через модель
|
||||
model_name = context_obj.__class__.__name__
|
||||
|
||||
if model_name == 'Post':
|
||||
object_author = getattr(context_obj, 'author', None)
|
||||
object_url = f"/feed/#{context_obj.id}"
|
||||
object_type = "публикации"
|
||||
elif model_name == 'Document':
|
||||
object_author = getattr(context_obj, 'uploaded_by', None)
|
||||
object_url = f"/documents/{context_obj.id}/"
|
||||
object_type = "документа"
|
||||
elif hasattr(context_obj, 'employee'): # Request
|
||||
object_author = getattr(context_obj, 'employee', None)
|
||||
object_url = f"/requests/{context_obj.id}/"
|
||||
object_type = "заявки"
|
||||
|
||||
# 1. Уведомляем автора объекта
|
||||
if object_author and object_author.id != author.id:
|
||||
recipients_set.add(object_author)
|
||||
|
||||
# 2. Уведомляем всех, кто участвовал в обсуждении
|
||||
from ..models import Message
|
||||
previous_commenters = Message.objects.filter(
|
||||
chat=chat,
|
||||
is_deleted=False
|
||||
).exclude(
|
||||
author_id=author.id
|
||||
).values_list('author_id', flat=True).distinct()
|
||||
|
||||
if previous_commenters:
|
||||
from django.contrib.auth import get_user_model
|
||||
User = get_user_model()
|
||||
recipients_set.update(
|
||||
User.objects.filter(id__in=previous_commenters, is_active=True)
|
||||
)
|
||||
|
||||
# Отправляем уведомления
|
||||
author_name = author.get_full_name() or author.username
|
||||
|
||||
for recipient in recipients_set:
|
||||
_send_notification(
|
||||
sender=author,
|
||||
recipient=recipient,
|
||||
verb='commented',
|
||||
action_object=message,
|
||||
target=context_obj,
|
||||
description=truncate_message(content, 100),
|
||||
action_url=object_url or f"/chat/{chat.id}/",
|
||||
data={
|
||||
'title': f'{author_name} оставил комментарий к {object_type}',
|
||||
'chat_id': chat.id,
|
||||
'message_id': message.id,
|
||||
'author_id': author.id,
|
||||
'object_type': model_name,
|
||||
'object_id': context_obj.id,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def notify_chat_added(chat, new_users, added_by=None):
|
||||
"""
|
||||
Отправляет уведомления пользователям о добавлении в чат.
|
||||
|
||||
@ -511,12 +511,13 @@ CELERY_TASK_TRACK_STARTED = True
|
||||
CELERY_TASK_TIME_LIMIT = 30 * 60 # 30 минут максимум на задачу
|
||||
CELERY_TASK_SOFT_TIME_LIMIT = 25 * 60 # Мягкий лимит - 25 минут
|
||||
|
||||
# Приоритеты очередей
|
||||
CELERY_TASK_ROUTES = {
|
||||
'notifications.*': {'queue': 'notifications'},
|
||||
'documents.tasks.*': {'queue': 'default'},
|
||||
'employees.tasks.*': {'queue': 'default'},
|
||||
}
|
||||
# Приоритеты очередей (закомментировано для упрощения в development)
|
||||
# В production можно раскомментировать для масштабирования
|
||||
# CELERY_TASK_ROUTES = {
|
||||
# 'notifications.*': {'queue': 'notifications'},
|
||||
# 'documents.tasks.*': {'queue': 'default'},
|
||||
# 'employees.tasks.*': {'queue': 'default'},
|
||||
# }
|
||||
|
||||
# Настройки для периодических задач (Celery Beat)
|
||||
CELERY_BEAT_SCHEDULER = 'django_celery_beat.schedulers:DatabaseScheduler'
|
||||
|
||||
@ -2,16 +2,19 @@
|
||||
|
||||
import { UserProvider } from "@/contexts/UserContext";
|
||||
import { CalendarProvider } from "@/contexts/CalendarContext";
|
||||
import { NotificationsProvider } from "@/contexts/NotificationsContext";
|
||||
import { ReactNode } from "react";
|
||||
import { Toaster } from "sonner";
|
||||
|
||||
export function Providers({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<UserProvider>
|
||||
<CalendarProvider>
|
||||
<Toaster position="top-right" richColors />
|
||||
{children}
|
||||
</CalendarProvider>
|
||||
<NotificationsProvider>
|
||||
<CalendarProvider>
|
||||
<Toaster position="top-right" richColors />
|
||||
{children}
|
||||
</CalendarProvider>
|
||||
</NotificationsProvider>
|
||||
</UserProvider>
|
||||
);
|
||||
}
|
||||
|
||||
195
frontend/src/contexts/NotificationsContext.tsx
Normal file
195
frontend/src/contexts/NotificationsContext.tsx
Normal file
@ -0,0 +1,195 @@
|
||||
"use client";
|
||||
|
||||
import React, { createContext, useContext, useEffect, useState, useCallback } from 'react';
|
||||
import apiClient from '@/lib/api';
|
||||
import wsManager from '@/lib/websocketManager';
|
||||
|
||||
interface NotificationsContextType {
|
||||
notifications: any[];
|
||||
unreadCount: number;
|
||||
loading: boolean;
|
||||
error: Error | null;
|
||||
markAsRead: (id: number) => Promise<void>;
|
||||
markAllAsRead: () => Promise<void>;
|
||||
markCategoryAsRead: (category: string) => Promise<void>;
|
||||
deleteNotification: (id: number) => Promise<void>;
|
||||
deleteAllRead: () => Promise<number>;
|
||||
}
|
||||
|
||||
const NotificationsContext = createContext<NotificationsContextType | undefined>(undefined);
|
||||
|
||||
export function NotificationsProvider({ children }: { children: React.ReactNode }) {
|
||||
const [notifications, setNotifications] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
const [unreadCount, setUnreadCount] = useState(0);
|
||||
|
||||
// Загрузка начальных данных
|
||||
useEffect(() => {
|
||||
async function fetchNotifications() {
|
||||
try {
|
||||
const data = await apiClient.getNotifications({
|
||||
page_size: 50,
|
||||
unread_only: true
|
||||
});
|
||||
const notifs = data.notifications || data.results || data;
|
||||
const notificationsArray = Array.isArray(notifs) ? notifs : [];
|
||||
setNotifications(notificationsArray);
|
||||
setUnreadCount(data.unread_count ?? notificationsArray.length);
|
||||
} catch (err) {
|
||||
setError(err as Error);
|
||||
setNotifications([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
fetchNotifications();
|
||||
}, []);
|
||||
|
||||
// ЕДИНСТВЕННЫЙ WebSocket через singleton manager
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined' || !localStorage.getItem('access_token')) {
|
||||
console.log('[NotificationsContext] WebSocket disabled: not in browser or not authenticated');
|
||||
return;
|
||||
}
|
||||
|
||||
let unsubscribe: (() => void) | null = null;
|
||||
|
||||
const setupWebSocket = () => {
|
||||
// Подписываемся на сообщения через singleton
|
||||
unsubscribe = wsManager.subscribe((data) => {
|
||||
// Игнорируем служебные события
|
||||
if (['ping', 'list_update', 'new_message', 'marked_read', 'message_edited', 'reaction_added', 'reaction_removed', 'poll_update'].includes(data.type)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Новое уведомление
|
||||
if (data.type === 'notification' && data.notification) {
|
||||
console.log('[NotificationsContext] New notification:', data.notification);
|
||||
|
||||
setNotifications(prev => {
|
||||
if (prev.some(n => n.id === data.notification.id)) {
|
||||
return prev;
|
||||
}
|
||||
return [data.notification, ...prev];
|
||||
});
|
||||
|
||||
if (!data.notification.is_read) {
|
||||
setUnreadCount(prev => prev + 1);
|
||||
}
|
||||
}
|
||||
|
||||
// Обновление счетчика
|
||||
if (data.type === 'unread_count' && typeof data.count === 'number') {
|
||||
console.log('[NotificationsContext] Unread count update:', data.count);
|
||||
setUnreadCount(data.count);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
setupWebSocket();
|
||||
|
||||
return () => {
|
||||
if (unsubscribe) {
|
||||
unsubscribe();
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Синхронизация между вкладками через CustomEvent
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
const handleMarkedRead = (e: Event) => {
|
||||
const customEvent = e as CustomEvent;
|
||||
const { id } = customEvent.detail;
|
||||
setNotifications(prev =>
|
||||
prev.map(n => (n.id === id ? { ...n, is_read: true } : n))
|
||||
);
|
||||
setUnreadCount(prev => Math.max(0, prev - 1));
|
||||
};
|
||||
|
||||
const handleAllMarkedRead = () => {
|
||||
setNotifications(prev => prev.map(n => ({ ...n, is_read: true })));
|
||||
setUnreadCount(0);
|
||||
};
|
||||
|
||||
window.addEventListener('notification-marked-read', handleMarkedRead);
|
||||
window.addEventListener('all-notifications-marked-read', handleAllMarkedRead);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('notification-marked-read', handleMarkedRead);
|
||||
window.removeEventListener('all-notifications-marked-read', handleAllMarkedRead);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const markAsRead = useCallback(async (id: number) => {
|
||||
await apiClient.markNotificationAsRead(id);
|
||||
setNotifications(prev =>
|
||||
prev.map(n => (n.id === id ? { ...n, is_read: true } : n))
|
||||
);
|
||||
setUnreadCount(prev => Math.max(0, prev - 1));
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
window.dispatchEvent(new CustomEvent('notification-marked-read', { detail: { id } }));
|
||||
}
|
||||
}, []);
|
||||
|
||||
const markAllAsRead = useCallback(async () => {
|
||||
await apiClient.markAllNotificationsAsRead();
|
||||
setNotifications(prev => prev.map(n => ({ ...n, is_read: true })));
|
||||
setUnreadCount(0);
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
window.dispatchEvent(new CustomEvent('all-notifications-marked-read'));
|
||||
}
|
||||
}, []);
|
||||
|
||||
const markCategoryAsRead = useCallback(async (category: string) => {
|
||||
await apiClient.markCategoryAsRead(category);
|
||||
const updatedNotifications = notifications.map(n =>
|
||||
n.category === category ? { ...n, is_read: true } : n
|
||||
);
|
||||
setNotifications(updatedNotifications);
|
||||
const newUnreadCount = updatedNotifications.filter(n => !n.is_read).length;
|
||||
setUnreadCount(newUnreadCount);
|
||||
}, [notifications]);
|
||||
|
||||
const deleteNotification = useCallback(async (id: number) => {
|
||||
await apiClient.deleteNotification(id);
|
||||
setNotifications(prev => prev.filter(n => n.id !== id));
|
||||
}, []);
|
||||
|
||||
const deleteAllRead = useCallback(async (): Promise<number> => {
|
||||
const result = await apiClient.deleteAllReadNotifications();
|
||||
setNotifications(prev => prev.filter(n => !n.is_read));
|
||||
return result.count;
|
||||
}, []);
|
||||
|
||||
const value = {
|
||||
notifications,
|
||||
unreadCount,
|
||||
loading,
|
||||
error,
|
||||
markAsRead,
|
||||
markAllAsRead,
|
||||
markCategoryAsRead,
|
||||
deleteNotification,
|
||||
deleteAllRead,
|
||||
};
|
||||
|
||||
return (
|
||||
<NotificationsContext.Provider value={value}>
|
||||
{children}
|
||||
</NotificationsContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useNotifications() {
|
||||
const context = useContext(NotificationsContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useNotifications must be used within NotificationsProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
@ -169,323 +169,5 @@ export function useChats() {
|
||||
return { chats, loading, error };
|
||||
}
|
||||
|
||||
export function useNotifications() {
|
||||
const [notifications, setNotifications] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
const [unreadCount, setUnreadCount] = useState(0);
|
||||
|
||||
// Загрузка начальных данных
|
||||
useEffect(() => {
|
||||
async function fetchNotifications() {
|
||||
try {
|
||||
// Для бейджей загружаем только непрочитанные (первые 50)
|
||||
const data = await apiClient.getNotifications({
|
||||
page_size: 50,
|
||||
unread_only: true
|
||||
});
|
||||
// Бэкенд возвращает { notifications: [...], unread_count: N }
|
||||
const notifs = data.notifications || data.results || data;
|
||||
// Убеждаемся что notifs это массив
|
||||
const notificationsArray = Array.isArray(notifs) ? notifs : [];
|
||||
setNotifications(notificationsArray);
|
||||
// Используем unread_count из API или считаем сами
|
||||
setUnreadCount(data.unread_count ?? notificationsArray.length);
|
||||
} catch (err) {
|
||||
setError(err as Error);
|
||||
setNotifications([]); // В случае ошибки устанавливаем пустой массив
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
fetchNotifications();
|
||||
}, []);
|
||||
|
||||
// WebSocket для realtime обновлений
|
||||
useEffect(() => {
|
||||
// Проверяем что мы в браузере и пользователь авторизован
|
||||
if (typeof window === 'undefined' || !localStorage.getItem('access_token')) {
|
||||
console.log('[Notifications] WebSocket disabled: not in browser or not authenticated');
|
||||
return;
|
||||
}
|
||||
|
||||
let ws: WebSocket | null = null;
|
||||
let reconnectTimeout: NodeJS.Timeout | null = null;
|
||||
let reconnectAttempts = 0;
|
||||
const maxReconnectAttempts = 5;
|
||||
|
||||
const connect = () => {
|
||||
try {
|
||||
// Используем функцию getWebSocketUrl для определения URL
|
||||
const backendUrl = process.env.NEXT_PUBLIC_BACKEND_URL || 'http://localhost:9000';
|
||||
const protocol = backendUrl.startsWith('https') ? 'wss:' : 'ws:';
|
||||
const host = backendUrl.replace(/^https?:\/\//, '').replace(/\/$/, '');
|
||||
let wsUrl = `${protocol}//${host}/ws/`;
|
||||
|
||||
// Добавляем токен
|
||||
const token = localStorage.getItem('access_token');
|
||||
if (token) {
|
||||
wsUrl += `?token=${token}`;
|
||||
}
|
||||
|
||||
console.log('[Notifications] WebSocket connecting to:', wsUrl);
|
||||
console.log('[Notifications] Backend URL:', backendUrl);
|
||||
ws = new WebSocket(wsUrl);
|
||||
|
||||
ws.onopen = () => {
|
||||
console.log('[Notifications] WebSocket connected');
|
||||
reconnectAttempts = 0;
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
|
||||
// Игнорируем служебные события
|
||||
if (['ping', 'list_update', 'new_message', 'marked_read', 'message_edited', 'reaction_added', 'reaction_removed', 'poll_update'].includes(data.type)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Новое уведомление
|
||||
if (data.type === 'notification' && data.notification) {
|
||||
console.log('[Notifications] New notification:', data.notification);
|
||||
|
||||
// Добавляем в начало списка если это новое
|
||||
setNotifications(prev => {
|
||||
// Проверяем не дубликат ли
|
||||
if (prev.some(n => n.id === data.notification.id)) {
|
||||
return prev;
|
||||
}
|
||||
return [data.notification, ...prev];
|
||||
});
|
||||
|
||||
// Обновляем счетчик если непрочитанное
|
||||
if (!data.notification.is_read) {
|
||||
setUnreadCount(prev => prev + 1);
|
||||
}
|
||||
}
|
||||
|
||||
// Обновление счетчика
|
||||
if (data.type === 'unread_count' && typeof data.count === 'number') {
|
||||
console.log('[Notifications] Unread count update:', data.count);
|
||||
setUnreadCount(data.count);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[Notifications] Parse error:', error);
|
||||
}
|
||||
};
|
||||
|
||||
ws.onerror = (error) => {
|
||||
console.error('[Notifications] WebSocket error:', error);
|
||||
console.error('[Notifications] WebSocket URL was:', wsUrl);
|
||||
console.error('[Notifications] WebSocket state:', ws?.readyState);
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
console.log('[Notifications] WebSocket disconnected');
|
||||
ws = null;
|
||||
|
||||
// Автоматический reconnect
|
||||
if (reconnectAttempts < maxReconnectAttempts) {
|
||||
reconnectAttempts++;
|
||||
const delay = Math.min(1000 * Math.pow(2, reconnectAttempts), 30000);
|
||||
console.log(`[Notifications] Reconnecting in ${delay}ms... (${reconnectAttempts}/${maxReconnectAttempts})`);
|
||||
reconnectTimeout = setTimeout(connect, delay);
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[Notifications] Connection failed:', error);
|
||||
}
|
||||
};
|
||||
|
||||
connect();
|
||||
|
||||
// Cleanup
|
||||
return () => {
|
||||
if (reconnectTimeout) {
|
||||
clearTimeout(reconnectTimeout);
|
||||
}
|
||||
if (ws) {
|
||||
ws.close();
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Синхронизация между экземплярами хука через CustomEvent
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
const handleMarkedRead = (e: Event) => {
|
||||
const customEvent = e as CustomEvent;
|
||||
const { id } = customEvent.detail;
|
||||
setNotifications(prev =>
|
||||
prev.map(n => (n.id === id ? { ...n, is_read: true } : n))
|
||||
);
|
||||
setUnreadCount(prev => Math.max(0, prev - 1));
|
||||
};
|
||||
|
||||
const handleAllMarkedRead = () => {
|
||||
setNotifications(prev => prev.map(n => ({ ...n, is_read: true })));
|
||||
setUnreadCount(0);
|
||||
};
|
||||
|
||||
const handleCategoryMarkedRead = async (e: Event) => {
|
||||
const customEvent = e as CustomEvent;
|
||||
const { category, verbs } = customEvent.detail;
|
||||
|
||||
// Используем переданные verb'ы для проверки
|
||||
setNotifications(prev => {
|
||||
const updated = prev.map(n => {
|
||||
const isInCategory = verbs.includes(n.verb);
|
||||
return isInCategory ? { ...n, is_read: true } : n;
|
||||
});
|
||||
|
||||
// Пересчитываем счетчик
|
||||
const newUnreadCount = updated.filter(n => !n.is_read).length;
|
||||
setUnreadCount(newUnreadCount);
|
||||
|
||||
return updated;
|
||||
});
|
||||
};
|
||||
|
||||
const handleDeleted = (e: Event) => {
|
||||
const customEvent = e as CustomEvent;
|
||||
const { id, wasUnread } = customEvent.detail;
|
||||
setNotifications(prev => prev.filter(n => n.id !== id));
|
||||
if (wasUnread) {
|
||||
setUnreadCount(prev => Math.max(0, prev - 1));
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('notification-marked-read', handleMarkedRead);
|
||||
window.addEventListener('notifications-all-marked-read', handleAllMarkedRead);
|
||||
window.addEventListener('category-marked-read', handleCategoryMarkedRead);
|
||||
window.addEventListener('notification-deleted', handleDeleted);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('notification-marked-read', handleMarkedRead);
|
||||
window.removeEventListener('notifications-all-marked-read', handleAllMarkedRead);
|
||||
window.removeEventListener('category-marked-read', handleCategoryMarkedRead);
|
||||
window.removeEventListener('notification-deleted', handleDeleted);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const markAsRead = async (id: number) => {
|
||||
try {
|
||||
await apiClient.markNotificationAsRead(id);
|
||||
setNotifications(prev =>
|
||||
prev.map(n => (n.id === id ? { ...n, is_read: true } : n))
|
||||
);
|
||||
setUnreadCount(prev => Math.max(0, prev - 1));
|
||||
|
||||
// Синхронизация между экземплярами хука через CustomEvent
|
||||
if (typeof window !== 'undefined') {
|
||||
window.dispatchEvent(new CustomEvent('notification-marked-read', {
|
||||
detail: { id }
|
||||
}));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to mark notification as read:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const markAllAsRead = async () => {
|
||||
try {
|
||||
await apiClient.markAllNotificationsAsRead();
|
||||
setNotifications(prev => prev.map(n => ({ ...n, is_read: true })));
|
||||
setUnreadCount(0);
|
||||
|
||||
// Синхронизация между экземплярами хука через CustomEvent
|
||||
if (typeof window !== 'undefined') {
|
||||
window.dispatchEvent(new CustomEvent('notifications-all-marked-read'));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to mark all notifications as read:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteNotification = async (id: number) => {
|
||||
try {
|
||||
await apiClient.deleteNotification(id);
|
||||
|
||||
// Проверяем было ли непрочитанным ДО удаления
|
||||
const deletedNotif = notifications.find(n => n.id === id);
|
||||
const wasUnread = deletedNotif && !deletedNotif.is_read;
|
||||
|
||||
setNotifications(prev => prev.filter(n => n.id !== id));
|
||||
if (wasUnread) {
|
||||
setUnreadCount(prev => Math.max(0, prev - 1));
|
||||
}
|
||||
|
||||
// Синхронизация между экземплярами хука через CustomEvent
|
||||
if (typeof window !== 'undefined') {
|
||||
window.dispatchEvent(new CustomEvent('notification-deleted', {
|
||||
detail: { id, wasUnread }
|
||||
}));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to delete notification:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteAllRead = async () => {
|
||||
try {
|
||||
const result = await apiClient.deleteAllReadNotifications();
|
||||
setNotifications(prev => prev.filter(n => !n.is_read));
|
||||
return result.count;
|
||||
} catch (err) {
|
||||
console.error('Failed to delete all read notifications:', err);
|
||||
return 0;
|
||||
}
|
||||
};
|
||||
|
||||
const markCategoryAsRead = async (category: string) => {
|
||||
try {
|
||||
const result = await apiClient.markCategoryAsRead(category);
|
||||
|
||||
// Импортируем getVerbCategory, getVerbsByCategory для обработки
|
||||
const { getVerbCategory, getVerbsByCategory } = await import('@/lib/verbTranslations');
|
||||
const categoryVerbs = getVerbsByCategory(category);
|
||||
|
||||
// Помечаем уведомления этой категории как прочитанные
|
||||
setNotifications(prev =>
|
||||
prev.map(n => {
|
||||
const isInCategory = categoryVerbs.includes(n.verb);
|
||||
return isInCategory ? { ...n, is_read: true } : n;
|
||||
})
|
||||
);
|
||||
|
||||
// Пересчитываем счетчик непрочитанных
|
||||
setNotifications(prev => {
|
||||
const newUnreadCount = prev.filter(n => !n.is_read).length;
|
||||
setUnreadCount(newUnreadCount);
|
||||
return prev;
|
||||
});
|
||||
|
||||
// Синхронизация между экземплярами хука через CustomEvent
|
||||
if (typeof window !== 'undefined') {
|
||||
window.dispatchEvent(new CustomEvent('category-marked-read', {
|
||||
detail: { category, verbs: categoryVerbs }
|
||||
}));
|
||||
}
|
||||
|
||||
return result.count;
|
||||
} catch (err) {
|
||||
console.error('Failed to mark category as read:', err);
|
||||
return 0;
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
notifications,
|
||||
loading,
|
||||
error,
|
||||
unreadCount,
|
||||
markAsRead,
|
||||
markAllAsRead,
|
||||
markCategoryAsRead,
|
||||
deleteNotification,
|
||||
deleteAllRead
|
||||
};
|
||||
}
|
||||
// Re-export useNotifications from context for backwards compatibility
|
||||
export { useNotifications } from '@/contexts/NotificationsContext';
|
||||
|
||||
@ -1,122 +0,0 @@
|
||||
/**
|
||||
* WebSocket хук для realtime уведомлений
|
||||
* Подключается к UserConsumer на /ws/ и слушает события уведомлений
|
||||
*/
|
||||
import { useEffect, useRef, useCallback } from 'react';
|
||||
import { getWebSocketUrl } from '@/lib/url';
|
||||
|
||||
interface NotificationMessage {
|
||||
type: 'notification' | 'unread_count' | 'ping' | 'list_update' | 'new_message' | 'marked_read';
|
||||
notification?: any;
|
||||
count?: number;
|
||||
}
|
||||
|
||||
interface UseNotificationWebSocketOptions {
|
||||
onNotification: (notification: any) => void;
|
||||
onUnreadCountUpdate: (count: number) => void;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
export function useNotificationWebSocket({
|
||||
onNotification,
|
||||
onUnreadCountUpdate,
|
||||
enabled = true
|
||||
}: UseNotificationWebSocketOptions) {
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
const reconnectTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const reconnectAttemptsRef = useRef(0);
|
||||
const maxReconnectAttempts = 5;
|
||||
const reconnectDelay = 3000;
|
||||
|
||||
const connect = useCallback(() => {
|
||||
if (!enabled || wsRef.current?.readyState === WebSocket.OPEN) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
let wsUrl = getWebSocketUrl();
|
||||
|
||||
// Добавляем токен
|
||||
const token = localStorage.getItem('access_token');
|
||||
if (token) {
|
||||
wsUrl += `${wsUrl.includes('?') ? '&' : '?'}token=${token}`;
|
||||
}
|
||||
|
||||
console.log('[NotificationWS] Connecting to:', wsUrl);
|
||||
const ws = new WebSocket(wsUrl);
|
||||
wsRef.current = ws;
|
||||
|
||||
ws.onopen = () => {
|
||||
console.log('[NotificationWS] Connected');
|
||||
reconnectAttemptsRef.current = 0;
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
try {
|
||||
const data: NotificationMessage = JSON.parse(event.data);
|
||||
|
||||
// Игнорируем ping и другие события
|
||||
if (data.type === 'ping' ||
|
||||
data.type === 'list_update' ||
|
||||
data.type === 'new_message' ||
|
||||
data.type === 'marked_read') {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('[NotificationWS] Message:', data);
|
||||
|
||||
// Обрабатываем уведомления
|
||||
if (data.type === 'notification' && data.notification) {
|
||||
onNotification(data.notification);
|
||||
}
|
||||
|
||||
// Обрабатываем счетчик
|
||||
if (data.type === 'unread_count' && typeof data.count === 'number') {
|
||||
onUnreadCountUpdate(data.count);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[NotificationWS] Parse error:', error);
|
||||
}
|
||||
};
|
||||
|
||||
ws.onerror = (error) => {
|
||||
console.error('[NotificationWS] Error:', error);
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
console.log('[NotificationWS] Disconnected');
|
||||
wsRef.current = null;
|
||||
|
||||
// Автоматический reconnect
|
||||
if (enabled && reconnectAttemptsRef.current < maxReconnectAttempts) {
|
||||
reconnectAttemptsRef.current++;
|
||||
console.log(`[NotificationWS] Reconnecting... (${reconnectAttemptsRef.current}/${maxReconnectAttempts})`);
|
||||
reconnectTimeoutRef.current = setTimeout(connect, reconnectDelay);
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[NotificationWS] Connection failed:', error);
|
||||
}
|
||||
}, [enabled, onNotification, onUnreadCountUpdate]);
|
||||
|
||||
useEffect(() => {
|
||||
if (enabled) {
|
||||
connect();
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (reconnectTimeoutRef.current) {
|
||||
clearTimeout(reconnectTimeoutRef.current);
|
||||
}
|
||||
if (wsRef.current) {
|
||||
wsRef.current.close();
|
||||
wsRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [enabled, connect]);
|
||||
|
||||
return {
|
||||
isConnected: wsRef.current?.readyState === WebSocket.OPEN,
|
||||
reconnect: connect,
|
||||
};
|
||||
}
|
||||
151
frontend/src/lib/websocketManager.ts
Normal file
151
frontend/src/lib/websocketManager.ts
Normal file
@ -0,0 +1,151 @@
|
||||
/**
|
||||
* Singleton WebSocket Manager
|
||||
* Управляет единственным WebSocket соединением для всего приложения
|
||||
*/
|
||||
|
||||
type MessageHandler = (data: any) => void;
|
||||
|
||||
class WebSocketManager {
|
||||
private static instance: WebSocketManager | null = null;
|
||||
private ws: WebSocket | null = null;
|
||||
private reconnectTimeout: NodeJS.Timeout | null = null;
|
||||
private reconnectAttempts = 0;
|
||||
private maxReconnectAttempts = 5;
|
||||
private messageHandlers: Set<MessageHandler> = new Set();
|
||||
private isConnecting = false;
|
||||
|
||||
private constructor() {}
|
||||
|
||||
static getInstance(): WebSocketManager {
|
||||
if (!WebSocketManager.instance) {
|
||||
WebSocketManager.instance = new WebSocketManager();
|
||||
}
|
||||
return WebSocketManager.instance;
|
||||
}
|
||||
|
||||
connect() {
|
||||
// Если уже подключены или подключаемся, не создаем новое соединение
|
||||
if (this.ws?.readyState === WebSocket.OPEN || this.isConnecting) {
|
||||
console.log('[WS Manager] Already connected or connecting');
|
||||
return;
|
||||
}
|
||||
|
||||
// Проверяем что мы в браузере и пользователь авторизован
|
||||
if (typeof window === 'undefined' || !localStorage.getItem('access_token')) {
|
||||
console.log('[WS Manager] Not in browser or not authenticated');
|
||||
return;
|
||||
}
|
||||
|
||||
this.isConnecting = true;
|
||||
|
||||
try {
|
||||
const backendUrl = process.env.NEXT_PUBLIC_BACKEND_URL || 'http://localhost:9000';
|
||||
const protocol = backendUrl.startsWith('https') ? 'wss:' : 'ws:';
|
||||
const host = backendUrl.replace(/^https?:\/\//, '').replace(/\/$/, '');
|
||||
let wsUrl = `${protocol}//${host}/ws/`;
|
||||
|
||||
const token = localStorage.getItem('access_token');
|
||||
if (token) {
|
||||
wsUrl += `?token=${token}`;
|
||||
}
|
||||
|
||||
console.log('[WS Manager] Backend URL:', backendUrl);
|
||||
console.log('[WS Manager] Protocol:', protocol);
|
||||
console.log('[WS Manager] Host:', host);
|
||||
console.log('[WS Manager] Full WS URL:', wsUrl);
|
||||
console.log('[WS Manager] Has token:', !!token);
|
||||
|
||||
this.ws = new WebSocket(wsUrl);
|
||||
|
||||
this.ws.onopen = () => {
|
||||
console.log('[WS Manager] Connected');
|
||||
this.isConnecting = false;
|
||||
this.reconnectAttempts = 0;
|
||||
};
|
||||
|
||||
this.ws.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
|
||||
// Отправляем сообщение всем подписчикам
|
||||
this.messageHandlers.forEach(handler => {
|
||||
try {
|
||||
handler(data);
|
||||
} catch (err) {
|
||||
console.error('[WS Manager] Handler error:', err);
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[WS Manager] Parse error:', error);
|
||||
}
|
||||
};
|
||||
|
||||
this.ws.onerror = (error) => {
|
||||
console.error('[WS Manager] Error event:', error);
|
||||
console.error('[WS Manager] WebSocket state:', this.ws?.readyState);
|
||||
console.error('[WS Manager] WebSocket URL was:', this.ws?.url);
|
||||
this.isConnecting = false;
|
||||
};
|
||||
|
||||
this.ws.onclose = (event) => {
|
||||
console.log('[WS Manager] Disconnected');
|
||||
console.log('[WS Manager] Close event code:', event.code);
|
||||
console.log('[WS Manager] Close event reason:', event.reason);
|
||||
console.log('[WS Manager] Close event wasClean:', event.wasClean);
|
||||
this.isConnecting = false;
|
||||
this.ws = null;
|
||||
|
||||
// Автоматический reconnect
|
||||
if (this.reconnectAttempts < this.maxReconnectAttempts) {
|
||||
this.reconnectAttempts++;
|
||||
const delay = Math.min(1000 * Math.pow(2, this.reconnectAttempts), 30000);
|
||||
console.log(`[WS Manager] Reconnecting in ${delay}ms... (${this.reconnectAttempts}/${this.maxReconnectAttempts})`);
|
||||
this.reconnectTimeout = setTimeout(() => this.connect(), delay);
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[WS Manager] Connection failed:', error);
|
||||
this.isConnecting = false;
|
||||
}
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
if (this.reconnectTimeout) {
|
||||
clearTimeout(this.reconnectTimeout);
|
||||
this.reconnectTimeout = null;
|
||||
}
|
||||
|
||||
if (this.ws) {
|
||||
this.ws.close();
|
||||
this.ws = null;
|
||||
}
|
||||
|
||||
this.isConnecting = false;
|
||||
}
|
||||
|
||||
subscribe(handler: MessageHandler) {
|
||||
this.messageHandlers.add(handler);
|
||||
|
||||
// Если есть подписчик и нет соединения, подключаемся
|
||||
if (this.messageHandlers.size > 0 && !this.ws && !this.isConnecting) {
|
||||
this.connect();
|
||||
}
|
||||
|
||||
// Возвращаем функцию отписки
|
||||
return () => {
|
||||
this.messageHandlers.delete(handler);
|
||||
|
||||
// Если больше нет подписчиков, отключаемся
|
||||
if (this.messageHandlers.size === 0) {
|
||||
console.log('[WS Manager] No more subscribers, disconnecting');
|
||||
this.disconnect();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
getState(): number {
|
||||
return this.ws?.readyState ?? WebSocket.CLOSED;
|
||||
}
|
||||
}
|
||||
|
||||
export default WebSocketManager.getInstance();
|
||||
Loading…
x
Reference in New Issue
Block a user