Refactor realtime sync and message UI

This commit is contained in:
lizerk 2026-04-01 15:20:27 +03:00
parent 0833eb13ce
commit 613248905e
23 changed files with 2018 additions and 1130 deletions

View File

@ -60,6 +60,18 @@ logger = logging.getLogger(__name__)
User = get_user_model()
def _parse_bool_query_param(value, default=True):
if value is None:
return default
normalized = str(value).strip().lower()
if normalized in {'1', 'true', 'yes', 'on'}:
return True
if normalized in {'0', 'false', 'no', 'off'}:
return False
return default
class ChatViewSet(viewsets.ModelViewSet):
"""
ViewSet для управления чатами
@ -563,7 +575,12 @@ class ChatViewSet(viewsets.ModelViewSet):
@action(detail=True, methods=['get'])
def messages(self, request, pk=None):
"""Загрузка сообщений чата (пагинация по времени)"""
"""Загрузка сообщений чата (пагинация по времени).
Query params:
- mark_read=false: не обновлять last_read_message при after_id/after_ts.
Нужен для тихой синхронизации клиента после разрыва WebSocket.
"""
chat = self.get_object()
# Проверка доступа
@ -582,6 +599,10 @@ class ChatViewSet(viewsets.ModelViewSet):
limit = min(int(request.query_params.get('limit', 50)), 100)
except (ValueError, TypeError):
limit = 50
mark_read = _parse_bool_query_param(
request.query_params.get('mark_read'),
default=True,
)
queryset = chat.messages.filter(is_deleted=False).select_related(
'author', 'reply_to', 'reply_to__author', 'poll'
@ -633,7 +654,7 @@ class ChatViewSet(viewsets.ModelViewSet):
# Автоотметка при загрузке НОВЫХ сообщений (after_id/after_ts)
# При scroll вверх (before_id) - не отмечаем
if (after_id or after_ts) and messages:
if mark_read and (after_id or after_ts) and messages:
self._auto_mark_read(chat, request.user, messages)
return Response({

View File

@ -16,6 +16,10 @@ from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from ..models import Notification, UserChannelPreferences
from ..realtime import (
send_notification_read_event,
send_notifications_read_all_event,
)
from .serializers import (
ChannelPreferencesSerializer,
CountResponseSerializer,
@ -188,8 +192,12 @@ def mark_as_read(request, notification_id):
id=notification_id,
recipient=request.user
)
was_unread = notification.unread
notification.mark_as_read()
if was_unread:
send_notification_read_event(request.user.id, notification_id)
return Response({
'status': 'success',
'notification_id': notification_id
@ -252,8 +260,16 @@ def mark_all_as_read(request):
if verb:
queryset = queryset.filter(verb=verb)
notification_ids = list(queryset.values_list('id', flat=True))
count = queryset.mark_all_as_read()
if notification_ids:
send_notifications_read_all_event(
request.user.id,
notification_ids,
category=verb,
)
return Response({
'status': 'success',
'count': count
@ -299,8 +315,16 @@ def mark_category_as_read(request):
verb__in=verbs,
)
notification_ids = list(queryset.values_list('id', flat=True))
count = queryset.mark_all_as_read()
if notification_ids:
send_notifications_read_all_event(
request.user.id,
notification_ids,
category=request.data.get('category'),
)
return Response({
'status': 'success',
'verbs': verbs,

View File

@ -0,0 +1,74 @@
from asgiref.sync import async_to_sync
from channels.layers import get_channel_layer
from .models import Notification
def get_unread_notifications_count(user_id):
return Notification.objects.filter(
recipient_id=user_id,
unread=True,
deleted=False,
).count()
def _send_user_event(user_id, event):
channel_layer = get_channel_layer()
if channel_layer is None:
return
async_to_sync(channel_layer.group_send)(f"user_{user_id}", event)
def send_notification_read_event(user_id, notification_id, unread_count=None):
count = (
get_unread_notifications_count(user_id)
if unread_count is None
else unread_count
)
_send_user_event(
user_id,
{
"type": "notification_read",
"notification_id": notification_id,
"unread_count": count,
},
)
_send_user_event(
user_id,
{
"type": "notification_count_update",
"count": count,
},
)
def send_notifications_read_all_event(
user_id,
notification_ids,
unread_count=None,
category=None,
):
count = (
get_unread_notifications_count(user_id)
if unread_count is None
else unread_count
)
_send_user_event(
user_id,
{
"type": "notifications_read_all",
"notification_ids": notification_ids,
"category": category,
"unread_count": count,
},
)
_send_user_event(
user_id,
{
"type": "notification_count_update",
"count": count,
},
)

View File

@ -215,6 +215,27 @@ class UserConsumer(ChatConsumerMixin, AsyncJsonWebsocketConsumer):
{"type": "unread_count", "count": event.get("count", 0)}
)
async def notification_read(self, event):
"""Синхронизация прочтения одного уведомления между сессиями."""
await self.send_json(
{
"type": "notification_read",
"notification_id": event.get("notification_id"),
"unread_count": event.get("unread_count"),
}
)
async def notifications_read_all(self, event):
"""Синхронизация массового прочтения уведомлений между сессиями."""
await self.send_json(
{
"type": "notifications_read_all",
"notification_ids": event.get("notification_ids", []),
"category": event.get("category"),
"unread_count": event.get("unread_count"),
}
)
# ==================== Обработка событий закупок ====================
async def poll_update(self, event):

View File

@ -0,0 +1,108 @@
from unittest.mock import patch
import pytest
from notifications.models import Notification
pytestmark = pytest.mark.django_db
class TestNotificationRealtimeEvents:
def test_mark_as_read_sends_realtime_event(self, api_client, user_factory):
user = user_factory()
notification = Notification.objects.create(
recipient=user,
verb="request_updated",
description="Заявка обновлена",
)
api_client.force_authenticate(user=user)
with patch(
"notifications.api.views.send_notification_read_event"
) as send_event:
response = api_client.post(
f"/api/v1/notifications/{notification.id}/read/"
)
assert response.status_code == 200
notification.refresh_from_db()
assert notification.unread is False
send_event.assert_called_once_with(user.id, notification.id)
def test_mark_all_as_read_sends_realtime_event_with_ids(
self, api_client, user_factory
):
user = user_factory()
first = Notification.objects.create(
recipient=user,
verb="request_new",
description="Новая заявка",
)
second = Notification.objects.create(
recipient=user,
verb="request_updated",
description="Заявка обновлена",
)
api_client.force_authenticate(user=user)
with patch(
"notifications.api.views.send_notifications_read_all_event"
) as send_event:
response = api_client.post("/api/v1/notifications/read-all/")
assert response.status_code == 200
assert (
Notification.objects.filter(
recipient=user,
unread=True,
deleted=False,
).count()
== 0
)
send_event.assert_called_once()
args, kwargs = send_event.call_args
assert args[0] == user.id
assert set(args[1]) == {first.id, second.id}
assert kwargs == {"category": None}
def test_mark_category_as_read_sends_category_realtime_event(
self, api_client, user_factory
):
user = user_factory()
matching = Notification.objects.create(
recipient=user,
verb="request_updated",
description="Заявка обновлена",
)
other = Notification.objects.create(
recipient=user,
verb="document_uploaded",
description="Документ загружен",
)
api_client.force_authenticate(user=user)
with patch(
"notifications.api.views.send_notifications_read_all_event"
) as send_event:
response = api_client.post(
"/api/v1/notifications/category/read/",
{
"verbs": ["request_updated"],
"category": "Заявки",
},
format="json",
)
assert response.status_code == 200
matching.refresh_from_db()
other.refresh_from_db()
assert matching.unread is False
assert other.unread is True
send_event.assert_called_once()
args, kwargs = send_event.call_args
assert args == (user.id, [matching.id])
assert kwargs == {"category": "Заявки"}

View File

@ -544,3 +544,70 @@ class TestChatMarkedReadEvent:
import asyncio
with pytest.raises(asyncio.TimeoutError):
await comm2.receive_json_from(timeout=2)
await comm1.disconnect()
await comm2.disconnect()
class TestNotificationStateEvents:
"""Тесты синхронизации состояния уведомлений через user channel."""
@pytest.mark.asyncio
async def test_notification_read_sync_between_sessions(self, ws_communicator, user):
comm1 = await ws_communicator(user=user)
comm2 = await ws_communicator(user=user)
connected1, _ = await comm1.connect()
connected2, _ = await comm2.connect()
assert connected1 and connected2
channel_layer = get_channel_layer()
await channel_layer.group_send(
f'user_{user.id}',
{
'type': 'notification_read',
'notification_id': 321,
'unread_count': 4,
}
)
response1 = await comm1.receive_json_from(timeout=5)
response2 = await comm2.receive_json_from(timeout=5)
assert response1['type'] == 'notification_read'
assert response1['notification_id'] == 321
assert response1['unread_count'] == 4
assert response2['type'] == 'notification_read'
await comm1.disconnect()
await comm2.disconnect()
@pytest.mark.asyncio
async def test_notifications_read_all_sync_between_sessions(self, ws_communicator, user):
comm1 = await ws_communicator(user=user)
comm2 = await ws_communicator(user=user)
connected1, _ = await comm1.connect()
connected2, _ = await comm2.connect()
assert connected1 and connected2
channel_layer = get_channel_layer()
await channel_layer.group_send(
f'user_{user.id}',
{
'type': 'notifications_read_all',
'notification_ids': [11, 12, 13],
'category': 'Заявки',
'unread_count': 0,
}
)
response1 = await comm1.receive_json_from(timeout=5)
response2 = await comm2.receive_json_from(timeout=5)
assert response1['type'] == 'notifications_read_all'
assert response1['notification_ids'] == [11, 12, 13]
assert response1['category'] == 'Заявки'
assert response1['unread_count'] == 0
assert response2['type'] == 'notifications_read_all'
await comm1.disconnect()
await comm2.disconnect()

File diff suppressed because it is too large Load Diff

View File

@ -20,75 +20,14 @@ import {
} from "lucide-react";
import { AppShell } from "../../../../components/AppShell";
import { apiClient } from "@/lib/api";
import type { Chat } from "@/types/api";
import { getChatAvatar, getChatInitials, getChatTitle, getUserFullName } from "@/lib/messages/chatUtils";
import type { Chat, User } from "@/types/api";
import { useUser } from "@/contexts/UserContext";
import { resolveMediaUrl } from "@/lib/url";
function getUserFullName(lastName?: string, firstName?: string): string {
return `${lastName || ""} ${firstName || ""}`.trim();
}
function getInterlocutorFromParticipants(chat: Chat, currentUserId?: number) {
const participants = (chat.participants || []).filter(
(p): p is Exclude<typeof p, number> => typeof p === "object" && p !== null
);
return participants.find((p) => p.id !== currentUserId);
}
function getInterlocutorFromParticipantDetails(chat: Chat, currentUserId?: number) {
return (chat.participant_details || []).find((p) => p.id !== currentUserId);
}
function getChatTitle(chat: Chat, currentUserId?: number): string {
const chatKind = chat.chat_type || chat.type;
const rawName = (chat.name || "").trim();
if (chatKind === "direct" || chatKind === "private" || !rawName || rawName.toLowerCase() === "диалог") {
if (chat.interlocutor?.name?.trim()) {
return chat.interlocutor.name.trim();
}
const detailsOther = getInterlocutorFromParticipantDetails(chat, currentUserId);
if (detailsOther?.name?.trim()) {
return detailsOther.name.trim();
}
const other = getInterlocutorFromParticipants(chat, currentUserId);
if (other && typeof other === "object") {
const name = getUserFullName(other.last_name, other.first_name);
if (name) return name;
if (other.email) return other.email;
}
}
return rawName || "Диалог";
}
function getChatAvatar(chat: Chat, currentUserId?: number): string {
const chatKind = chat.chat_type || chat.type;
if (chatKind === "direct" || chatKind === "private" || (chat.name || "").trim().toLowerCase() === "диалог") {
if (chat.interlocutor?.avatar) return chat.interlocutor.avatar;
const detailsOther = getInterlocutorFromParticipantDetails(chat, currentUserId);
if (detailsOther?.avatar) return detailsOther.avatar;
const other = getInterlocutorFromParticipants(chat, currentUserId);
if (other?.avatar) return other.avatar;
}
return chat.avatar || "";
}
function getChatInitials(chat: Chat, currentUserId?: number): string {
const title = getChatTitle(chat, currentUserId);
return (
title
.split(" ")
.filter(Boolean)
.slice(0, 2)
.map((p) => p[0]?.toUpperCase() || "")
.join("") || "Ч"
);
}
type MemberSearchResult = Pick<User, "id" | "first_name" | "last_name" | "email" | "avatar"> & {
name?: string;
};
function getChatTypeLabel(chat: Chat): string {
const type = chat.chat_type || chat.type;
@ -137,7 +76,7 @@ export default function ChatSettingsPage() {
// Состояние модала добавления участников
const [isAddMemberModalOpen, setIsAddMemberModalOpen] = useState(false);
const [memberSearchQuery, setMemberSearchQuery] = useState("");
const [memberSearchResults, setMemberSearchResults] = useState<any[]>([]);
const [memberSearchResults, setMemberSearchResults] = useState<MemberSearchResult[]>([]);
const [memberSearchLoading, setMemberSearchLoading] = useState(false);
// Состояние для отображения полного списка участников
@ -314,7 +253,7 @@ export default function ChatSettingsPage() {
setActionLoading("edit");
try {
// Обновляем название и описание
const updatedChat = await apiClient.updateChat(chatId, {
await apiClient.updateChat(chatId, {
name: editName.trim() || undefined,
description: editDescription.trim() || undefined,
});
@ -366,7 +305,7 @@ export default function ChatSettingsPage() {
const currentMemberIds = new Set(
chat?.participant_details?.map(p => p.id) || []
);
const filtered = results.filter((r: any) => !currentMemberIds.has(r.id));
const filtered = (results as MemberSearchResult[]).filter((result) => !currentMemberIds.has(result.id));
setMemberSearchResults(filtered);
} catch (e) {
@ -397,9 +336,9 @@ export default function ChatSettingsPage() {
// Используем свежие данные чата для фильтрации
const currentMemberIds = new Set(
refreshedChat?.participant_details?.map((p: any) => p.id) || []
refreshedChat?.participant_details?.map((participant) => participant.id) || []
);
const filtered = results.filter((r: any) => !currentMemberIds.has(r.id));
const filtered = (results as MemberSearchResult[]).filter((result) => !currentMemberIds.has(result.id));
setMemberSearchResults(filtered);
}
@ -509,11 +448,8 @@ export default function ChatSettingsPage() {
}
const chatType = chat.chat_type || chat.type;
const isPrivateChat = chatType === "private" || chatType === "direct";
const isGroupChat = chatType === "group";
// Чаты с управлением участниками (группы, каналы, объявления)
const hasMembers = chatType === "group" || chatType === "channel" || chatType === "announcement";
const canManageChat = chat.can_manage ?? false;
// Проверка прав: владелец или админ
const isOwner = chat.created_by === currentUserId;

View File

@ -4,112 +4,22 @@ import { useEffect, useMemo, useState } from "react";
import { AppShell } from "../../components/AppShell";
import { apiClient } from "@/lib/api";
import { resolveMediaUrl } from "@/lib/url";
import { ChatIdentity, getChatAvatar, getChatInitials, getChatTitle } from "@/lib/messages/chatUtils";
import type { Chat } from "@/types/api";
import { Search, MessageCircle, Pin, BellOff, Filter, Plus, X, Users, Globe, Radio, Upload, Image as ImageIcon, Megaphone, MessageSquare } from "lucide-react";
import { Search, MessageCircle, Pin, BellOff, Filter, Plus, X, Users, Globe, Radio, Image as ImageIcon, Megaphone, MessageSquare } from "lucide-react";
import Link from "next/link";
import Image from "next/image";
import { useUser } from "@/contexts/UserContext";
import { useRouter } from "next/navigation";
function getUserFullName(lastName?: string, firstName?: string): string {
return `${lastName || ""} ${firstName || ""}`.trim();
}
function normalizeName(value?: string | null): string {
return (value || "").replace(/\s+/g, " ").trim().toLowerCase();
}
function isLikelyCurrentUserName(candidate: string, user?: { first_name?: string; last_name?: string; patronymic?: string; email?: string } | null): boolean {
if (!user) return false;
const c = normalizeName(candidate);
if (!c) return false;
const fn = normalizeName(user.first_name);
const ln = normalizeName(user.last_name);
const pn = normalizeName(user.patronymic);
const email = normalizeName(user.email);
if (email && c === email) return true;
const variants = new Set<string>([
normalizeName(`${ln} ${fn}`),
normalizeName(`${fn} ${ln}`),
normalizeName(`${ln} ${fn} ${pn}`),
normalizeName(`${fn} ${pn} ${ln}`),
normalizeName(`${fn} ${pn}`),
]);
if (variants.has(c)) return true;
if (fn && ln && c.includes(fn) && c.includes(ln)) return true;
return false;
}
function getInterlocutorFromParticipants(chat: Chat, currentUserId?: number) {
const participants = (chat.participants || []).filter(
(p): p is Exclude<typeof p, number> => typeof p === "object" && p !== null
);
return participants.find((p) => p.id !== currentUserId);
}
function getInterlocutorFromParticipantDetails(chat: Chat, currentUserId?: number) {
return (chat.participant_details || []).find((p) => p.id !== currentUserId);
}
function getInterlocutorNameFromParticipantNames(chat: Chat, currentUser?: { first_name?: string; last_name?: string; patronymic?: string; email?: string } | null): string {
const names = (chat.participant_names || []).map((n) => (n || "").trim()).filter(Boolean);
if (!names.length) return "";
const other = names.find((n) => !isLikelyCurrentUserName(n, currentUser));
return other || names[0] || "";
}
function getChatTitle(chat: Chat, currentUserId?: number, currentUser?: { first_name?: string; last_name?: string; patronymic?: string; email?: string } | null): string {
const chatKind = chat.chat_type || chat.type;
const rawName = (chat.name || "").trim();
if (chatKind === "direct" || chatKind === "private" || !rawName || rawName.toLowerCase() === "диалог") {
const detailsOther = getInterlocutorFromParticipantDetails(chat, currentUserId);
if (detailsOther?.name?.trim()) {
return detailsOther.name.trim();
}
const other = getInterlocutorFromParticipants(chat, currentUserId);
if (other && typeof other === "object") {
const name = getUserFullName(other.last_name, other.first_name);
if (name) return name;
if (other.email) return other.email;
}
const namesFallback = getInterlocutorNameFromParticipantNames(chat, currentUser);
if (namesFallback) return namesFallback;
}
return rawName || "Диалог";
}
function getChatAvatar(chat: Chat, currentUserId?: number): string {
const chatKind = chat.chat_type || chat.type;
if (chatKind === "direct" || chatKind === "private" || (chat.name || "").trim().toLowerCase() === "диалог") {
const detailsOther = getInterlocutorFromParticipantDetails(chat, currentUserId);
if (detailsOther?.avatar) return detailsOther.avatar;
const other = getInterlocutorFromParticipants(chat, currentUserId);
if (other?.avatar) return other.avatar;
}
return chat.avatar || "";
}
function getChatInitials(chat: Chat, currentUserId?: number, currentUser?: { first_name?: string; last_name?: string; patronymic?: string; email?: string } | null): string {
const title = getChatTitle(chat, currentUserId, currentUser);
return title
.split(" ")
.filter(Boolean)
.slice(0, 2)
.map((p) => p[0]?.toUpperCase() || "")
.join("") || "Ч";
}
type UnreadFilter = "all" | "unread" | "read";
type PinnedFilter = "all" | "pinned" | "unpinned";
type CreateChatPayload = {
type: string;
name: string;
description?: string;
avatar?: File;
};
function getChatTypeIcon(chat: Chat) {
const chatType = chat.chat_type || chat.type;
@ -170,7 +80,7 @@ export default function MessagesPage() {
const currentUserPatronymic = user?.patronymic;
const currentUserEmail = user?.email;
const currentUserForMatch = useMemo(
const currentUserForMatch = useMemo<ChatIdentity>(
() => ({
first_name: currentUserFirstName,
last_name: currentUserLastName,
@ -182,8 +92,8 @@ export default function MessagesPage() {
const [chats, setChats] = useState<Chat[]>([]);
const [search, setSearch] = useState("");
const [chatTypeFilter, setChatTypeFilter] = useState<string>("all");
const [filterUnread, setFilterUnread] = useState<'all' | 'unread' | 'read'>('all');
const [filterPinned, setFilterPinned] = useState<'all' | 'pinned' | 'unpinned'>('all');
const [filterUnread, setFilterUnread] = useState<UnreadFilter>('all');
const [filterPinned, setFilterPinned] = useState<PinnedFilter>('all');
const [filtersOpen, setFiltersOpen] = useState(false);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
@ -343,7 +253,7 @@ export default function MessagesPage() {
setCreating(true);
try {
const chatData: any = {
const chatData: CreateChatPayload = {
type: newChatType,
name: newChatName.trim(),
};
@ -462,7 +372,7 @@ export default function MessagesPage() {
<div className="mb-4 flex flex-col gap-2 rounded-xl border border-gray-200 bg-gray-50 p-3">
<select
value={filterUnread}
onChange={(e) => setFilterUnread(e.target.value as any)}
onChange={(e) => setFilterUnread(e.target.value as UnreadFilter)}
className="rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm text-gray-800"
>
<option value="all">Все чаты</option>
@ -472,7 +382,7 @@ export default function MessagesPage() {
<select
value={filterPinned}
onChange={(e) => setFilterPinned(e.target.value as any)}
onChange={(e) => setFilterPinned(e.target.value as PinnedFilter)}
className="rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm text-gray-800"
>
<option value="all">Все чаты</option>

View File

@ -0,0 +1,112 @@
"use client";
import Link from "next/link";
import Image from "next/image";
import { ArrowLeft, Bell, BellOff, Pin } from "lucide-react";
import type { Chat } from "@/types/api";
import { getChatAvatar, getChatInitials, getChatTitle } from "@/lib/messages/chatUtils";
import { resolveMediaUrl } from "@/lib/url";
type ChatDialogHeaderProps = {
chat: Chat;
chatId: number;
currentUserId?: number;
isPinned: boolean;
notificationsEnabled: boolean;
onTogglePin: () => void;
onToggleNotifications: () => void;
};
function getChatSubtitle(chat: Chat): string {
const type = chat.chat_type || chat.type;
switch (type) {
case "group":
return "Групповой чат";
case "channel":
return "Канал";
case "announcement":
return "Канал объявлений";
case "global":
return "Глобальный чат";
case "comments":
return "Комментарии";
default:
return "Диалог";
}
}
export default function ChatDialogHeader({
chat,
chatId,
currentUserId,
isPinned,
notificationsEnabled,
onTogglePin,
onToggleNotifications,
}: ChatDialogHeaderProps) {
const avatar = getChatAvatar(chat, currentUserId);
const title = getChatTitle(chat, currentUserId);
return (
<header className="flex shrink-0 items-center justify-between gap-3 border-b border-gray-100 pb-3">
<div className="flex items-center gap-3">
<div className="flex h-11 w-11 items-center justify-center overflow-hidden rounded-full bg-sky-400 text-sm font-semibold text-white">
{avatar ? (
<div className="relative h-full w-full">
<Image src={resolveMediaUrl(avatar)} alt={title} fill unoptimized className="object-cover" sizes="44px" />
</div>
) : (
getChatInitials(chat, currentUserId)
)}
</div>
<Link href={`/messages/${chatId}/settings`} className="hover:opacity-80 transition">
<div>
<p className="text-sm font-semibold text-gray-900">{title}</p>
</div>
<p className="text-xs text-gray-500">{getChatSubtitle(chat)}</p>
</Link>
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={onTogglePin}
className={`flex h-10 w-10 items-center justify-center rounded-full border transition ${
isPinned
? "border-sky-500 bg-sky-50 text-sky-700 hover:bg-sky-100"
: "border-gray-200 bg-white text-gray-600 hover:bg-gray-50 hover:text-sky-700"
}`}
title={isPinned ? "Открепить чат" : "Закрепить чат"}
aria-label={isPinned ? "Открепить чат" : "Закрепить чат"}
>
<Pin size={16} className={isPinned ? "fill-current" : ""} />
</button>
<button
type="button"
onClick={onToggleNotifications}
className={`flex h-10 w-10 items-center justify-center rounded-full border transition ${
notificationsEnabled
? "border-gray-200 bg-white text-gray-600 hover:bg-gray-50 hover:text-sky-700"
: "border-amber-500 bg-amber-50 text-amber-700 hover:bg-amber-100"
}`}
title={notificationsEnabled ? "Отключить уведомления" : "Включить уведомления"}
aria-label={notificationsEnabled ? "Отключить уведомления" : "Включить уведомления"}
>
{notificationsEnabled ? <Bell size={16} /> : <BellOff size={16} />}
</button>
<Link
href="/messages"
aria-label="К списку чатов"
title="К списку чатов"
className="flex h-10 w-10 items-center justify-center rounded-full border border-gray-200 bg-white text-gray-600 transition hover:bg-gray-50 hover:text-sky-700"
>
<ArrowLeft size={16} />
</Link>
</div>
</header>
);
}

View File

@ -0,0 +1,41 @@
"use client";
import Image from "next/image";
import { X } from "lucide-react";
import type { MediaPreview } from "@/components/messages/ChatMessageItem";
type ChatMediaPreviewModalProps = {
preview: MediaPreview;
onClose: () => void;
};
export default function ChatMediaPreviewModal({ preview, onClose }: ChatMediaPreviewModalProps) {
return (
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-black/80 p-4" onClick={onClose}>
<button
type="button"
onClick={onClose}
className="absolute right-4 top-4 inline-flex h-10 w-10 items-center justify-center rounded-full bg-white/10 text-white hover:bg-white/20"
aria-label="Закрыть предпросмотр"
>
<X size={18} />
</button>
<div className="max-h-full max-w-[92vw]" onClick={(event) => event.stopPropagation()}>
{preview.type === "image" ? (
<Image
src={preview.src}
alt={preview.name}
width={1600}
height={1200}
unoptimized
className="max-h-[88vh] max-w-[92vw] rounded-lg object-contain"
/>
) : (
<video controls autoPlay className="max-h-[88vh] max-w-[92vw] rounded-lg bg-black" src={preview.src} />
)}
</div>
</div>
);
}

View File

@ -0,0 +1,298 @@
"use client";
import Image from "next/image";
import { ChevronRight, FileText } from "lucide-react";
import { resolveMediaUrl } from "@/lib/url";
import {
formatFileSize,
getMessageDate,
getMessageInitials,
getMessagePreviewText,
isAudioAttachment,
isImageAttachment,
isVideoAttachment,
messageTime,
} from "@/lib/messages/messageUtils";
import type { Message } from "@/types/api";
export type MediaPreview = {
type: "image" | "video";
src: string;
name: string;
};
type ChatMessageItemProps = {
message: Message;
currentUserId?: number;
repliedMessage?: Message | null;
isActionsOpen: boolean;
canManage: boolean;
canReply: boolean;
brokenMedia: Record<number, boolean>;
useOriginalImage: Record<number, boolean>;
onToggleActions: (messageId: number, anchor: { x: number; y: number }) => void;
onOpenMediaPreview: (preview: MediaPreview) => void;
onAttachmentLoad: (attachmentId: number) => void;
onAttachmentError: (attachmentId: number) => void;
onUseOriginalImage: (attachmentId: number) => void;
onReact: (message: Message, emoji: string) => void;
hasMyReaction: (message: Message, emoji: string) => boolean;
};
function getMessageAuthorLabel(message: Message): string {
return message.author_name || message.author?.last_name || message.sender?.last_name || "Сотрудник";
}
export default function ChatMessageItem({
message,
currentUserId,
repliedMessage,
isActionsOpen,
canManage,
canReply,
brokenMedia,
useOriginalImage,
onToggleActions,
onOpenMediaPreview,
onAttachmentLoad,
onAttachmentError,
onUseOriginalImage,
onReact,
hasMyReaction,
}: ChatMessageItemProps) {
const currentDate = getMessageDate(message);
const replyToId = repliedMessage?.id ?? message.reply_to_id ?? (typeof message.reply_to === "number" ? message.reply_to : null);
const isMine = Boolean(
currentUserId &&
(message.author_id === currentUserId || message.author?.id === currentUserId || message.sender?.id === currentUserId)
);
const hasActions = canReply || canManage;
return (
<div data-message-id={message.id} data-message-date={currentDate?.toISOString() || ""} className="mb-3 last:mb-0">
<div className={`flex ${isMine ? "justify-end" : "justify-start"}`}>
{!isMine ? (
<div className="relative mr-2 mt-1 h-8 w-8 shrink-0">
<div className="flex h-8 w-8 items-center justify-center overflow-hidden rounded-full bg-sky-400 text-[10px] font-semibold text-white">
{message.avatar || message.author?.avatar ? (
<Image
src={resolveMediaUrl(message.avatar || message.author?.avatar || "")}
alt={message.author_name || "Автор"}
width={32}
height={32}
unoptimized
className="h-full w-full object-cover"
/>
) : (
getMessageInitials(message)
)}
</div>
</div>
) : null}
<div className={`flex min-w-0 items-start gap-1 ${isMine ? "max-w-[88%] flex-row-reverse" : "max-w-[calc(100%-2.5rem)]"}`}>
<div className={`relative min-w-0 rounded-2xl px-3 py-2 pr-9 ${isMine ? "bg-sky-500 text-white" : "bg-white text-gray-900 ring-1 ring-gray-100"}`}>
{hasActions ? (
<div className="absolute right-1 top-1 z-20">
<button
type="button"
data-actions-trigger="true"
onClick={(event) => {
const rect = event.currentTarget.getBoundingClientRect();
onToggleActions(message.id, { x: rect.right, y: rect.top });
}}
className={`inline-flex h-6 w-6 items-center justify-center rounded-full border border-transparent bg-transparent text-gray-500 transition hover:text-sky-600 ${
isActionsOpen ? "rotate-90" : ""
}`}
title="Действия"
aria-label="Действия сообщения"
>
<ChevronRight size={14} />
</button>
</div>
) : null}
{!isMine ? <p className="mb-1 text-[11px] font-medium text-gray-500">{getMessageAuthorLabel(message)}</p> : null}
{replyToId ? (
<div className={`mb-2 rounded-lg border-l-2 px-2 py-1 text-xs ${isMine ? "border-sky-200 bg-sky-400/30 text-sky-50" : "border-gray-300 bg-gray-100 text-gray-600"}`}>
<p className="font-medium">{repliedMessage?.author_name || "Ответ на сообщение"}</p>
<p className="truncate">{repliedMessage ? getMessagePreviewText(repliedMessage) : `Сообщение #${replyToId}`}</p>
</div>
) : null}
{message.is_deleted ? (
<p className={`italic text-sm ${isMine ? "text-sky-100" : "text-gray-500"}`}>Сообщение удалено</p>
) : message.content ? (
<p className="whitespace-pre-wrap break-words text-sm leading-5">{message.content}</p>
) : null}
{message.attachments && message.attachments.length > 0 ? (
<div className="mt-2 space-y-2">
{message.attachments.map((attachment) => {
const fileUrl = resolveMediaUrl(attachment.file_url);
if (isImageAttachment(attachment)) {
if (brokenMedia[attachment.id]) {
return (
<a
key={attachment.id}
href={fileUrl}
target="_blank"
rel="noreferrer"
className="flex items-center gap-2 rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-800"
>
<FileText size={16} className="shrink-0" />
<span className="min-w-0 flex-1 truncate">Предпросмотр недоступен — открыть файл</span>
</a>
);
}
const hasThumbnail = Boolean(attachment.thumbnail);
const imageSrc = useOriginalImage[attachment.id]
? fileUrl
: resolveMediaUrl(attachment.thumbnail || attachment.file_url);
return (
<button
key={attachment.id}
type="button"
onClick={() => onOpenMediaPreview({ type: "image", src: fileUrl, name: attachment.file_name })}
className="block w-full overflow-hidden rounded-lg"
>
<Image
src={imageSrc}
alt={attachment.file_name}
width={attachment.width || 1280}
height={attachment.height || 720}
unoptimized
className="max-h-64 w-full rounded-lg object-cover"
onError={() => {
if (hasThumbnail && !useOriginalImage[attachment.id]) {
onUseOriginalImage(attachment.id);
return;
}
onAttachmentError(attachment.id);
}}
onLoad={() => onAttachmentLoad(attachment.id)}
sizes="(max-width: 768px) 100vw, 512px"
/>
</button>
);
}
if (isVideoAttachment(attachment)) {
if (brokenMedia[attachment.id]) {
return (
<a
key={attachment.id}
href={fileUrl}
target="_blank"
rel="noreferrer"
className="flex items-center gap-2 rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-800"
>
<FileText size={16} className="shrink-0" />
<span className="min-w-0 flex-1 truncate">Видео не поддерживается в браузере — открыть файл</span>
</a>
);
}
return (
<button
key={attachment.id}
type="button"
onClick={() => onOpenMediaPreview({ type: "video", src: fileUrl, name: attachment.file_name })}
className="block w-full overflow-hidden rounded-lg"
>
<video
preload="metadata"
playsInline
muted
src={fileUrl}
width={attachment.width || undefined}
height={attachment.height || undefined}
className="max-h-64 w-full rounded-lg bg-black"
onError={() => onAttachmentError(attachment.id)}
onLoadedData={() => onAttachmentLoad(attachment.id)}
/>
</button>
);
}
if (isAudioAttachment(attachment)) {
if (brokenMedia[attachment.id]) {
return (
<a
key={attachment.id}
href={fileUrl}
target="_blank"
rel="noreferrer"
className="flex items-center gap-2 rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-800"
>
<FileText size={16} className="shrink-0" />
<span className="min-w-0 flex-1 truncate">Аудио не поддерживается — открыть файл</span>
</a>
);
}
return (
<audio
key={attachment.id}
controls
preload="metadata"
className="w-full"
onError={() => onAttachmentError(attachment.id)}
onCanPlay={() => onAttachmentLoad(attachment.id)}
>
<source src={fileUrl} type={attachment.mime_type || "audio/mpeg"} />
</audio>
);
}
return (
<a
key={attachment.id}
href={fileUrl}
target="_blank"
rel="noreferrer"
className="flex items-center gap-2 rounded-lg border border-gray-200 bg-white/80 px-3 py-2 text-sm text-sky-700 hover:bg-white"
>
<FileText size={16} className="shrink-0" />
<span className="min-w-0 flex-1 truncate">{attachment.file_name}</span>
<span className="shrink-0 text-xs text-gray-500">{formatFileSize(attachment.file_size)}</span>
</a>
);
})}
</div>
) : null}
<p className={`mt-1 text-right text-[11px] ${isMine ? "text-sky-100" : "text-gray-400"}`}>{messageTime(message)}</p>
{Object.keys(message.reactions_summary || {}).length > 0 ? (
<div className="mt-2 flex flex-wrap gap-1">
{Object.entries(message.reactions_summary || {}).map(([emoji, meta]) => {
const mine = hasMyReaction(message, emoji);
return (
<button
key={`${message.id}-${emoji}`}
type="button"
onClick={() => onReact(message, emoji)}
className={`inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs transition ${
mine ? "bg-sky-100 text-sky-700 ring-1 ring-sky-300" : "bg-white/80 text-gray-700 ring-1 ring-gray-200 hover:bg-white"
}`}
title="Реакция"
>
<span>{emoji}</span>
<span>{meta.count}</span>
</button>
);
})}
</div>
) : null}
</div>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,100 @@
"use client";
import { Pencil, Reply, Smile, Trash2 } from "lucide-react";
import type { Message } from "@/types/api";
type MessageActionsMenuProps = {
anchor: { x: number; y: number };
message: Message;
canReply: boolean;
canManage: boolean;
recentReactions: string[];
onQuickReact: (emoji: string) => void;
onOpenReactionPicker: () => void;
onReply: () => void;
onEdit: () => void;
onDelete: () => void;
};
export default function MessageActionsMenu({
anchor,
message,
canReply,
canManage,
recentReactions,
onQuickReact,
onOpenReactionPicker,
onReply,
onEdit,
onDelete,
}: MessageActionsMenuProps) {
return (
<div
data-actions-menu="true"
className="fixed z-[60] flex min-w-[176px] flex-col gap-1 rounded-lg border border-gray-200 bg-white p-1 shadow-xl"
style={{
left: anchor.x,
top: anchor.y - 6,
transform: "translate(-100%, -100%)",
}}
>
{canReply ? (
<div className="mb-1 flex items-center gap-1 rounded-md bg-gray-50 p-1">
{recentReactions.map((emoji) => (
<button
key={`recent-${message.id}-${emoji}`}
type="button"
onClick={() => onQuickReact(emoji)}
className="inline-flex h-7 w-7 items-center justify-center rounded-md bg-white text-base hover:bg-sky-50"
title="Быстрая реакция"
>
{emoji}
</button>
))}
<button
type="button"
onClick={onOpenReactionPicker}
className="ml-auto inline-flex h-7 w-7 items-center justify-center rounded-md bg-white text-gray-600 hover:bg-sky-50 hover:text-sky-700"
title="Все смайлы"
>
<Smile size={14} />
</button>
</div>
) : null}
{canReply ? (
<button
type="button"
onClick={onReply}
className="inline-flex w-full items-center gap-1 rounded-md border border-sky-200 bg-sky-50 px-2 py-1 text-xs font-medium text-sky-700 hover:bg-sky-100"
>
<Reply size={12} />
Ответить
</button>
) : null}
{canManage ? (
<>
<button
type="button"
onClick={onEdit}
className="inline-flex w-full items-center gap-1 rounded-md border border-gray-200 bg-white px-2 py-1 text-xs font-medium text-gray-700 hover:bg-gray-50"
>
<Pencil size={12} />
Редактировать
</button>
<button
type="button"
onClick={onDelete}
className="inline-flex w-full items-center gap-1 rounded-md border border-red-200 bg-red-50 px-2 py-1 text-xs font-medium text-red-700 hover:bg-red-100"
>
<Trash2 size={12} />
Удалить
</button>
</>
) : null}
</div>
);
}

View File

@ -0,0 +1,206 @@
"use client";
import type React from "react";
import { Paperclip, Send, Smile, X } from "lucide-react";
type ReplyTarget = {
id: number;
author: string;
preview: string;
};
type MessageComposerProps = {
canSendMessages: boolean;
membershipRole?: string | null;
editingMessageId: number | null;
replyTo: ReplyTarget | null;
attachedFiles: File[];
messageText: string;
sending: boolean;
showEmojiPicker: boolean;
allReactions: string[];
fileInputRef: React.RefObject<HTMLInputElement | null>;
messageInputRef: React.RefObject<HTMLTextAreaElement | null>;
onPickFiles: () => void;
onFilesChange: (event: React.ChangeEvent<HTMLInputElement>) => void;
onRemoveFile: (index: number) => void;
onToggleEmojiPicker: () => void;
onSelectEmoji: (emoji: string) => void;
onInputClick: () => void;
onChangeMessage: (value: string) => void;
onTyping: () => void;
onSend: () => void;
onCancelEdit: () => void;
onCancelReply: () => void;
};
export default function MessageComposer({
canSendMessages,
membershipRole,
editingMessageId,
replyTo,
attachedFiles,
messageText,
sending,
showEmojiPicker,
allReactions,
fileInputRef,
messageInputRef,
onPickFiles,
onFilesChange,
onRemoveFile,
onToggleEmojiPicker,
onSelectEmoji,
onInputClick,
onChangeMessage,
onTyping,
onSend,
onCancelEdit,
onCancelReply,
}: MessageComposerProps) {
return (
<div className="shrink-0 border-t border-gray-100 bg-white pt-3">
{editingMessageId ? (
<div className="mb-2 flex items-start justify-between gap-2 rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-800">
<div className="min-w-0">
<p className="font-semibold">Режим редактирования</p>
<p className="truncate">Измените текст сообщения и отправьте</p>
</div>
<button
type="button"
onClick={onCancelEdit}
className="rounded-full p-0.5 text-amber-700 hover:bg-amber-100"
aria-label="Отменить редактирование"
>
<X size={12} />
</button>
</div>
) : null}
{replyTo ? (
<div className="mb-2 flex items-start justify-between gap-2 rounded-lg border border-sky-100 bg-sky-50 px-3 py-2 text-xs text-sky-800">
<div className="min-w-0">
<p className="font-semibold">Ответ: {replyTo.author}</p>
<p className="truncate">{replyTo.preview}</p>
</div>
<button
type="button"
onClick={onCancelReply}
className="rounded-full p-0.5 text-sky-700 hover:bg-sky-100"
aria-label="Отменить ответ"
>
<X size={12} />
</button>
</div>
) : null}
{attachedFiles.length > 0 ? (
<div className="mb-2 flex flex-wrap gap-2">
{attachedFiles.map((file, index) => (
<span
key={`${file.name}-${file.size}-${index}`}
className="inline-flex max-w-full items-center gap-1 rounded-full bg-sky-50 px-3 py-1 text-xs text-sky-700 ring-1 ring-sky-100"
>
<span className="truncate max-w-[180px]">{file.name}</span>
<button
type="button"
onClick={() => onRemoveFile(index)}
className="rounded-full p-0.5 hover:bg-sky-100"
aria-label="Удалить файл"
>
<X size={12} />
</button>
</span>
))}
</div>
) : null}
{canSendMessages ? (
<div className="flex items-start gap-2">
<input
ref={fileInputRef}
multiple
type="file"
className="hidden"
onChange={onFilesChange}
/>
<button
type="button"
onClick={onPickFiles}
disabled={Boolean(editingMessageId)}
className="inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-lg border border-gray-200 bg-white text-gray-600 leading-none transition hover:bg-gray-50 hover:text-sky-700 disabled:cursor-not-allowed disabled:opacity-50"
title={editingMessageId ? "При редактировании вложения недоступны" : "Добавить файлы"}
>
<Paperclip size={15} />
</button>
<div className="relative w-full" data-composer-emoji="true">
<button
type="button"
onClick={onToggleEmojiPicker}
className="absolute right-2 top-1/2 z-10 inline-flex h-6 w-6 -translate-y-1/2 items-center justify-center rounded-md text-gray-400 hover:bg-gray-100 hover:text-sky-600"
title="Смайлы"
>
<Smile size={14} />
</button>
{showEmojiPicker ? (
<div className="absolute bottom-full right-0 z-20 mb-2 w-[260px] rounded-lg border border-gray-200 bg-white p-2 shadow-xl">
<div className="grid max-h-48 grid-cols-8 gap-1 overflow-y-auto">
{allReactions.map((emoji) => (
<button
key={`composer-${emoji}`}
type="button"
onClick={() => onSelectEmoji(emoji)}
className="inline-flex h-8 w-8 items-center justify-center rounded-md text-base hover:bg-sky-50"
>
{emoji}
</button>
))}
</div>
</div>
) : null}
<textarea
ref={messageInputRef}
value={messageText}
onChange={(event) => {
onChangeMessage(event.target.value);
onTyping();
}}
onClick={onInputClick}
onKeyDown={(event) => {
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
onSend();
}
}}
rows={1}
placeholder={editingMessageId ? "Редактируйте сообщение..." : "Введите сообщение..."}
className="h-9 w-full resize-none rounded-lg border border-gray-200 bg-white px-3 py-2 pr-10 text-sm text-gray-900 outline-none ring-0 transition focus:border-sky-500 focus:ring-2 focus:ring-sky-100"
/>
</div>
<button
type="button"
onClick={onSend}
disabled={sending || (editingMessageId ? !messageText.trim() : (!messageText.trim() && attachedFiles.length === 0))}
className="inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-sky-500 text-white leading-none transition hover:bg-sky-600 disabled:cursor-not-allowed disabled:opacity-50"
title={editingMessageId ? "Сохранить" : "Отправить"}
>
<Send size={15} />
</button>
</div>
) : (
<div className="rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-center text-sm text-amber-800">
<p className="font-medium">У вас нет прав на отправку сообщений</p>
<p className="mt-1 text-xs text-amber-700">
{membershipRole === "guest"
? "Гости могут только просматривать сообщения и отправлять реакции"
: "Обратитесь к администратору чата для получения прав"}
</p>
</div>
)}
</div>
);
}

View File

@ -0,0 +1,36 @@
"use client";
import { X } from "lucide-react";
type ReactionPickerModalProps = {
allReactions: string[];
onClose: () => void;
onSelect: (emoji: string) => void;
};
export default function ReactionPickerModal({ allReactions, onClose, onSelect }: ReactionPickerModalProps) {
return (
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-black/50 p-2 sm:p-4" data-reaction-picker="true">
<div className="w-full max-w-[95vw] rounded-xl bg-white p-4 shadow-xl sm:max-w-md sm:rounded-2xl sm:p-6">
<div className="mb-3 flex items-center justify-between sm:mb-4">
<h3 className="text-base font-semibold text-gray-900 sm:text-lg">Выберите реакцию</h3>
<button type="button" onClick={onClose} className="rounded-full p-1 hover:bg-gray-100" aria-label="Закрыть">
<X size={18} className="text-gray-600 sm:h-5 sm:w-5" />
</button>
</div>
<div className="grid max-h-[60vh] grid-cols-6 gap-1.5 overflow-y-auto sm:max-h-[55vh] sm:grid-cols-8 sm:gap-2">
{allReactions.map((emoji) => (
<button
key={`picker-${emoji}`}
type="button"
onClick={() => onSelect(emoji)}
className="inline-flex h-9 w-9 items-center justify-center rounded-md text-lg hover:bg-sky-50"
>
{emoji}
</button>
))}
</div>
</div>
</div>
);
}

View File

@ -1,11 +1,28 @@
"use client";
import React, { createContext, useContext, useEffect, useState, useCallback } from 'react';
import React, { createContext, useContext, useEffect, useState, useCallback, useRef } from 'react';
import apiClient from '@/lib/api';
import wsManager from '@/lib/websocketManager';
type NotificationItem = {
id: number;
is_read?: boolean;
unread?: boolean;
category?: string;
[key: string]: unknown;
};
type NotificationSocketEvent = {
type: string;
notification?: NotificationItem;
notification_id?: number;
notification_ids?: number[];
unread_count?: number;
count?: number;
};
interface NotificationsContextType {
notifications: any[];
notifications: NotificationItem[];
unreadCount: number;
loading: boolean;
error: Error | null;
@ -19,34 +36,57 @@ interface NotificationsContextType {
const NotificationsContext = createContext<NotificationsContextType | undefined>(undefined);
export function NotificationsProvider({ children }: { children: React.ReactNode }) {
const [notifications, setNotifications] = useState<any[]>([]);
const [notifications, setNotifications] = useState<NotificationItem[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
const [unreadCount, setUnreadCount] = useState(0);
const [isWsConnected, setIsWsConnected] = useState(
typeof window !== 'undefined' && wsManager.getState() === WebSocket.OPEN
);
const fetchInFlightRef = useRef<Promise<void> | null>(null);
const syncUnreadNotifications = useCallback(async (showLoader = false) => {
if (fetchInFlightRef.current) {
return fetchInFlightRef.current;
}
const request = (async () => {
if (showLoader) {
setLoading(true);
}
// Загрузка начальных данных
useEffect(() => {
async function fetchNotifications() {
try {
const data = await apiClient.getNotifications({
page_size: 50,
unread_only: true
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);
setError(null);
} catch (err) {
setError(err as Error);
setNotifications([]);
if (showLoader) {
setNotifications([]);
}
} finally {
setLoading(false);
if (showLoader) {
setLoading(false);
}
fetchInFlightRef.current = null;
}
}
})();
fetchNotifications();
fetchInFlightRef.current = request;
return request;
}, []);
// Загрузка начальных данных
useEffect(() => {
void syncUnreadNotifications(true);
}, [syncUnreadNotifications]);
// ЕДИНСТВЕННЫЙ WebSocket через singleton manager
useEffect(() => {
if (typeof window === 'undefined' || !localStorage.getItem('access_token')) {
@ -54,107 +94,132 @@ export function NotificationsProvider({ children }: { children: React.ReactNode
return;
}
let unsubscribe: (() => void) | null = null;
const unsubscribeMessages = wsManager.subscribe((event) => {
const data = event as NotificationSocketEvent;
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 (['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 === 'notification' && data.notification) {
setNotifications(prev => {
if (prev.some(n => n.id === data.notification.id)) {
return prev;
}
}
return [data.notification, ...prev];
});
// Обновление счетчика
if (data.type === 'unread_count' && typeof data.count === 'number') {
console.log('[NotificationsContext] Unread count update:', data.count);
setUnreadCount(data.count);
const isRead = data.notification.is_read ?? !data.notification.unread;
if (!isRead) {
setUnreadCount(prev => prev + 1);
}
});
};
return;
}
setupWebSocket();
if (data.type === 'notification_read' && typeof data.notification_id === 'number') {
setNotifications(prev =>
prev.map(n => (n.id === data.notification_id ? { ...n, is_read: true, unread: false } : n))
);
if (typeof data.unread_count === 'number') {
setUnreadCount(data.unread_count);
}
return;
}
if (data.type === 'notifications_read_all') {
const ids = Array.isArray(data.notification_ids) ? new Set<number>(data.notification_ids) : null;
setNotifications(prev =>
prev.map((notification) => {
if (!ids || ids.has(notification.id)) {
return { ...notification, is_read: true, unread: false };
}
return notification;
})
);
if (typeof data.unread_count === 'number') {
setUnreadCount(data.unread_count);
}
return;
}
if (data.type === 'unread_count' && typeof data.count === 'number') {
setUnreadCount(data.count);
}
});
const unsubscribeStatus = wsManager.subscribeStatus((status) => {
setIsWsConnected(status.isConnected);
});
return () => {
if (unsubscribe) {
unsubscribe();
unsubscribeMessages();
unsubscribeStatus();
};
}, []);
// API fallback на время разрыва WebSocket и при возврате фокуса
useEffect(() => {
if (typeof window === 'undefined' || isWsConnected || !localStorage.getItem('access_token')) {
return;
}
const sync = () => {
void syncUnreadNotifications(false);
};
sync();
const intervalId = window.setInterval(sync, 15000);
const handleFocus = () => sync();
const handleVisibilityChange = () => {
if (document.visibilityState === 'visible') {
sync();
}
};
}, []);
// Синхронизация между вкладками через 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);
window.addEventListener('focus', handleFocus);
document.addEventListener('visibilitychange', handleVisibilityChange);
return () => {
window.removeEventListener('notification-marked-read', handleMarkedRead);
window.removeEventListener('all-notifications-marked-read', handleAllMarkedRead);
window.clearInterval(intervalId);
window.removeEventListener('focus', handleFocus);
document.removeEventListener('visibilitychange', handleVisibilityChange);
};
}, []);
}, [isWsConnected, syncUnreadNotifications]);
useEffect(() => {
if (!isWsConnected) {
return;
}
void syncUnreadNotifications(false);
}, [isWsConnected, syncUnreadNotifications]);
const markAsRead = useCallback(async (id: number) => {
await apiClient.markNotificationAsRead(id);
setNotifications(prev =>
prev.map(n => (n.id === id ? { ...n, is_read: true } : n))
prev.map(n => (n.id === id ? { ...n, is_read: true, unread: false } : 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 })));
setNotifications(prev => prev.map(n => ({ ...n, is_read: true, unread: false })));
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]);
setNotifications(prev => {
const updatedNotifications = prev.map(n =>
n.category === category ? { ...n, is_read: true, unread: false } : n
);
const newUnreadCount = updatedNotifications.filter(n => !n.is_read && n.unread !== false).length;
setUnreadCount(newUnreadCount);
return updatedNotifications;
});
}, []);
const deleteNotification = useCallback(async (id: number) => {
await apiClient.deleteNotification(id);

View File

@ -0,0 +1,71 @@
import { useEffect } from "react";
type SyncLatestMessagesOptions = {
limit?: number;
scrollOnSync?: boolean;
};
type UseChatFallbackSyncOptions = {
chatId: number | null;
isConnected: boolean;
userLoading: boolean;
hasUser: boolean;
messagesLoading: boolean;
hasMessages: boolean;
isNearBottom: () => boolean;
syncLatestMessages: (options?: SyncLatestMessagesOptions) => Promise<void>;
intervalMs?: number;
};
export function useChatFallbackSync({
chatId,
isConnected,
userLoading,
hasUser,
messagesLoading,
hasMessages,
isNearBottom,
syncLatestMessages,
intervalMs = 10000,
}: UseChatFallbackSyncOptions) {
useEffect(() => {
if (
typeof window === "undefined" ||
isConnected ||
!chatId ||
Number.isNaN(chatId) ||
userLoading ||
!hasUser ||
messagesLoading ||
!hasMessages
) {
return;
}
const sync = () => {
void syncLatestMessages({
limit: 20,
scrollOnSync: isNearBottom(),
});
};
sync();
const intervalId = window.setInterval(sync, intervalMs);
const handleFocus = () => sync();
const handleVisibilityChange = () => {
if (document.visibilityState === "visible") {
sync();
}
};
window.addEventListener("focus", handleFocus);
document.addEventListener("visibilitychange", handleVisibilityChange);
return () => {
window.clearInterval(intervalId);
window.removeEventListener("focus", handleFocus);
document.removeEventListener("visibilitychange", handleVisibilityChange);
};
}, [chatId, hasMessages, hasUser, intervalMs, isConnected, isNearBottom, messagesLoading, syncLatestMessages, userLoading]);
}

View File

@ -0,0 +1,50 @@
import { useCallback } from "react";
const WS_SILENT_RELOAD_COOLDOWN_MS = 60_000;
function getWsReloadGuardKey(chatId: number): string {
return `messages-ws-silent-reload:${chatId}`;
}
type ReconnectExhaustedDetails = {
chatId: number | null;
reconnectAttempts: number;
};
export function useSilentChatReloadGuard(chatId: number | null) {
const handleReconnectExhausted = useCallback((details: ReconnectExhaustedDetails) => {
if (typeof window === "undefined" || !chatId || Number.isNaN(chatId)) {
return;
}
const guardKey = getWsReloadGuardKey(chatId);
const now = Date.now();
const lastReloadAt = Number(window.sessionStorage.getItem(guardKey) || 0);
if (Number.isFinite(lastReloadAt) && now - lastReloadAt < WS_SILENT_RELOAD_COOLDOWN_MS) {
console.warn(
`Пропускаем тихую перезагрузку чата ${details.chatId}: cooldown еще не истек после ${details.reconnectAttempts} попыток.`
);
return;
}
window.sessionStorage.setItem(guardKey, String(now));
console.warn(
`WebSocket чата ${details.chatId} не восстановлен после ${details.reconnectAttempts} попыток, выполняем тихую перезагрузку.`
);
window.location.reload();
}, [chatId]);
const resetReloadGuard = useCallback(() => {
if (typeof window === "undefined" || !chatId || Number.isNaN(chatId)) {
return;
}
window.sessionStorage.removeItem(getWsReloadGuardKey(chatId));
}, [chatId]);
return {
handleReconnectExhausted,
resetReloadGuard,
};
}

View File

@ -3,10 +3,10 @@ import { getWebSocketUrl } from '@/lib/url';
interface WebSocketMessage {
type: string;
payload?: any;
payload?: Record<string, unknown>;
message_id?: number;
user_id?: number;
[key: string]: any;
[key: string]: unknown;
}
interface WebSocketHandlers {
@ -22,19 +22,27 @@ interface UseWebSocketOptions {
autoConnect?: boolean;
reconnectInterval?: number;
maxReconnectAttempts?: number;
onReconnectExhausted?: (details: {
chatId: number | null;
reconnectAttempts: number;
}) => void;
}
export function useWebSocket({
chatId,
autoConnect = true,
reconnectInterval = 3000,
maxReconnectAttempts = 5
maxReconnectAttempts = 5,
onReconnectExhausted,
}: UseWebSocketOptions) {
const [isConnected, setIsConnected] = useState(false);
const [reconnectAttempts, setReconnectAttempts] = useState(0);
const wsRef = useRef<WebSocket | null>(null);
const reconnectTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const isConnectingRef = useRef(false);
const reconnectAttemptsRef = useRef(0);
const shouldReconnectRef = useRef(autoConnect);
const connectRef = useRef<() => void>(() => {});
const handlers = useRef<WebSocketHandlers>({
onMessage: () => {},
@ -49,6 +57,13 @@ export function useWebSocket({
return;
}
shouldReconnectRef.current = autoConnect;
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current);
reconnectTimeoutRef.current = null;
}
isConnectingRef.current = true;
try {
@ -73,6 +88,7 @@ export function useWebSocket({
ws.onopen = () => {
setIsConnected(true);
reconnectAttemptsRef.current = 0;
setReconnectAttempts(0);
isConnectingRef.current = false;
@ -105,8 +121,6 @@ export function useWebSocket({
ws.onerror = (error) => {
console.error('❌ WebSocket error:', error);
console.warn('⚠️ WebSocket не может подключиться. Убедитесь что backend запущен через Daphne:');
console.warn(' cd backend && c:/Users/igor_/Dev/EUSRR/.venv/Scripts/python -m daphne -p 9000 eusrr_backend.asgi:application');
handlers.current.onError(error);
isConnectingRef.current = false;
};
@ -114,29 +128,39 @@ export function useWebSocket({
ws.onclose = () => {
setIsConnected(false);
isConnectingRef.current = false;
wsRef.current = null;
handlers.current.onDisconnect();
// Автоматическое переподключение
if (autoConnect && reconnectAttempts < maxReconnectAttempts) {
console.log(`🔄 Reconnecting... (attempt ${reconnectAttempts + 1}/${maxReconnectAttempts})`);
if (shouldReconnectRef.current && reconnectAttemptsRef.current < maxReconnectAttempts) {
const nextAttempt = reconnectAttemptsRef.current + 1;
reconnectAttemptsRef.current = nextAttempt;
setReconnectAttempts(nextAttempt);
console.log(`🔄 Reconnecting... (attempt ${nextAttempt}/${maxReconnectAttempts})`);
reconnectTimeoutRef.current = setTimeout(() => {
setReconnectAttempts(prev => prev + 1);
connect();
connectRef.current();
}, reconnectInterval);
} else if (reconnectAttempts >= maxReconnectAttempts) {
} else if (shouldReconnectRef.current && reconnectAttemptsRef.current >= maxReconnectAttempts) {
console.error('❌ Max reconnection attempts reached. Backend may not support WebSocket.');
console.warn('⚠️ Запустите backend через Daphne для WebSocket:');
console.warn(' cd C:/Users/igor_/Dev/EUSRR/backend');
console.warn(' c:/Users/igor_/Dev/EUSRR/.venv/Scripts/python -m daphne -p 9000 eusrr_backend.asgi:application');
onReconnectExhausted?.({
chatId,
reconnectAttempts: reconnectAttemptsRef.current,
});
}
};
} catch (error) {
console.error('Failed to create WebSocket:', error);
isConnectingRef.current = false;
}
}, [chatId, autoConnect, reconnectInterval, maxReconnectAttempts, reconnectAttempts]);
}, [chatId, autoConnect, reconnectInterval, maxReconnectAttempts, onReconnectExhausted]);
useEffect(() => {
connectRef.current = connect;
}, [connect]);
const disconnect = useCallback(() => {
shouldReconnectRef.current = false;
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current);
reconnectTimeoutRef.current = null;
@ -157,6 +181,7 @@ export function useWebSocket({
}
setIsConnected(false);
reconnectAttemptsRef.current = 0;
setReconnectAttempts(0);
isConnectingRef.current = false;
}, [chatId]);
@ -169,7 +194,7 @@ export function useWebSocket({
}
}, []);
const sendMessage = useCallback((message: any) => {
const sendMessage = useCallback((message: Record<string, unknown>) => {
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(JSON.stringify(message));
} else {
@ -179,6 +204,8 @@ export function useWebSocket({
// Подключение при монтировании или изменении chatId
useEffect(() => {
shouldReconnectRef.current = autoConnect;
if (autoConnect && chatId) {
connect();
}
@ -186,7 +213,7 @@ export function useWebSocket({
return () => {
disconnect();
};
}, [chatId, autoConnect]);
}, [chatId, autoConnect, connect, disconnect]);
// Очистка при размонтировании
useEffect(() => {

View File

@ -1537,6 +1537,7 @@ class ApiClient {
limit?: number;
before_id?: number;
after_id?: number;
mark_read?: boolean;
before?: string;
after?: string;
}
@ -1545,6 +1546,7 @@ class ApiClient {
if (params?.limit) queryParams.append('limit', params.limit.toString());
if (params?.before_id) queryParams.append('before_id', params.before_id.toString());
if (params?.after_id) queryParams.append('after_id', params.after_id.toString());
if (typeof params?.mark_read === 'boolean') queryParams.append('mark_read', String(params.mark_read));
if (params?.before) queryParams.append('before', params.before);
if (params?.after) queryParams.append('after', params.after);

View File

@ -0,0 +1,124 @@
import type { Chat, Message } from "@/types/api";
export type ChatIdentity = {
first_name?: string;
last_name?: string;
patronymic?: string;
email?: string;
} | null | undefined;
export function getUserFullName(lastName?: string, firstName?: string): string {
return `${lastName || ""} ${firstName || ""}`.trim();
}
function normalizeName(value?: string | null): string {
return (value || "").replace(/\s+/g, " ").trim().toLowerCase();
}
function isLikelyCurrentUserName(candidate: string, user?: ChatIdentity): boolean {
if (!user) return false;
const normalizedCandidate = normalizeName(candidate);
if (!normalizedCandidate) return false;
const firstName = normalizeName(user.first_name);
const lastName = normalizeName(user.last_name);
const patronymic = normalizeName(user.patronymic);
const email = normalizeName(user.email);
if (email && normalizedCandidate === email) return true;
const variants = new Set<string>([
normalizeName(`${lastName} ${firstName}`),
normalizeName(`${firstName} ${lastName}`),
normalizeName(`${lastName} ${firstName} ${patronymic}`),
normalizeName(`${firstName} ${patronymic} ${lastName}`),
normalizeName(`${firstName} ${patronymic}`),
]);
if (variants.has(normalizedCandidate)) return true;
return Boolean(firstName && lastName && normalizedCandidate.includes(firstName) && normalizedCandidate.includes(lastName));
}
export function getInterlocutorFromParticipants(chat: Chat, currentUserId?: number) {
const participants = (chat.participants || []).filter(
(participant): participant is Exclude<typeof participant, number> =>
typeof participant === "object" && participant !== null
);
return participants.find((participant) => participant.id !== currentUserId);
}
export function getInterlocutorFromParticipantDetails(chat: Chat, currentUserId?: number) {
return (chat.participant_details || []).find((participant) => participant.id !== currentUserId);
}
export function getInterlocutorNameFromParticipantNames(chat: Chat, currentUser?: ChatIdentity): string {
const names = (chat.participant_names || []).map((name) => (name || "").trim()).filter(Boolean);
if (!names.length) return "";
const otherParticipant = names.find((name) => !isLikelyCurrentUserName(name, currentUser));
return otherParticipant || names[0] || "";
}
export function getChatTitle(chat: Chat, currentUserId?: number, currentUser?: ChatIdentity): string {
const chatKind = chat.chat_type || chat.type;
const rawName = (chat.name || "").trim();
if (chatKind === "direct" || chatKind === "private" || !rawName || rawName.toLowerCase() === "диалог") {
if (chat.interlocutor?.name?.trim()) {
return chat.interlocutor.name.trim();
}
const detailsOther = getInterlocutorFromParticipantDetails(chat, currentUserId);
if (detailsOther?.name?.trim()) {
return detailsOther.name.trim();
}
const otherParticipant = getInterlocutorFromParticipants(chat, currentUserId);
if (otherParticipant) {
const participantName = getUserFullName(otherParticipant.last_name, otherParticipant.first_name);
if (participantName) return participantName;
if (otherParticipant.email) return otherParticipant.email;
}
const namesFallback = getInterlocutorNameFromParticipantNames(chat, currentUser);
if (namesFallback) return namesFallback;
}
return rawName || "Диалог";
}
export function getChatAvatar(chat: Chat, currentUserId?: number): string {
const chatKind = chat.chat_type || chat.type;
if (chatKind === "direct" || chatKind === "private" || (chat.name || "").trim().toLowerCase() === "диалог") {
if (chat.interlocutor?.avatar) return chat.interlocutor.avatar;
const detailsOther = getInterlocutorFromParticipantDetails(chat, currentUserId);
if (detailsOther?.avatar) return detailsOther.avatar;
const otherParticipant = getInterlocutorFromParticipants(chat, currentUserId);
if (otherParticipant?.avatar) return otherParticipant.avatar;
}
return chat.avatar || "";
}
export function getChatInitials(chat: Chat, currentUserId?: number, currentUser?: ChatIdentity): string {
return (
getChatTitle(chat, currentUserId, currentUser)
.split(" ")
.filter(Boolean)
.slice(0, 2)
.map((part) => part[0]?.toUpperCase() || "")
.join("") || "Ч"
);
}
export function uniqueMessagesById(items: Message[]): Message[] {
const map = new Map<number, Message>();
items.forEach((message) => {
map.set(message.id, message);
});
return Array.from(map.values());
}

View File

@ -0,0 +1,102 @@
import type { Message, MessageAttachment } from "@/types/api";
export function getMessageInitials(message: Message): string {
const name = (message.author_name || "").trim();
if (!name) return "С";
const parts = name.split(" ").filter(Boolean);
if (parts.length === 1) {
return parts[0].slice(0, 2).toUpperCase();
}
return `${parts[0][0] || ""}${parts[1][0] || ""}`.toUpperCase();
}
export function getMessageDate(message: Message): Date | null {
if (message.created_at) {
const date = new Date(message.created_at);
return Number.isNaN(date.getTime()) ? null : date;
}
if (message.created_ts) {
const date = new Date(message.created_ts);
return Number.isNaN(date.getTime()) ? null : date;
}
return null;
}
export function formatDayDivider(date: Date): string {
return date.toLocaleDateString("ru-RU", {
day: "2-digit",
month: "long",
year: "numeric",
});
}
export function messageTime(message: Message): string {
const date = getMessageDate(message);
if (!date) return "";
const now = new Date();
if (date.toDateString() === now.toDateString()) {
return date.toLocaleTimeString("ru-RU", { hour: "2-digit", minute: "2-digit" });
}
return date.toLocaleString("ru-RU", {
day: "2-digit",
month: "2-digit",
hour: "2-digit",
minute: "2-digit",
});
}
export function formatFileSize(size?: number): string {
if (!size || size <= 0) return "";
const kilobytes = size / 1024;
if (kilobytes < 1024) return `${Math.round(kilobytes)} КБ`;
const megabytes = kilobytes / 1024;
return `${megabytes.toFixed(1)} МБ`;
}
export function isImageAttachment(attachment: MessageAttachment): boolean {
const mime = (attachment.mime_type || "").toLowerCase();
const type = (attachment.file_type || "").toLowerCase();
const name = (attachment.file_name || "").toLowerCase();
const byExt = /\.(png|jpe?g|gif|webp|bmp|svg|avif)$/i.test(name);
return mime.startsWith("image/") || type === "image" || byExt;
}
export function isVideoAttachment(attachment: MessageAttachment): boolean {
const mime = (attachment.mime_type || "").toLowerCase();
const type = (attachment.file_type || "").toLowerCase();
const name = (attachment.file_name || "").toLowerCase();
const byExt = /\.(mp4|webm|mov|m4v|avi|mkv|3gp|mpeg|mpg)$/i.test(name);
return mime.startsWith("video/") || type === "video" || byExt;
}
export function isAudioAttachment(attachment: MessageAttachment): boolean {
const mime = (attachment.mime_type || "").toLowerCase();
const type = (attachment.file_type || "").toLowerCase();
return mime.startsWith("audio/") || type === "audio";
}
export function getMessagePreviewText(message: Message): string {
if (message.content?.trim()) return message.content.trim();
if (message.attachments?.length) return "[Вложение]";
return "[Сообщение]";
}
export function getReplyToId(message: Message): number | null {
if (typeof message.reply_to_id === "number") return message.reply_to_id;
if (typeof message.reply_to === "number") return message.reply_to;
if (message.reply_to && typeof message.reply_to === "object" && typeof message.reply_to.id === "number") {
return message.reply_to.id;
}
if (message.reply_to_message && typeof message.reply_to_message.id === "number") {
return message.reply_to_message.id;
}
return null;
}

View File

@ -3,7 +3,17 @@
* Управляет единственным WebSocket соединением для всего приложения
*/
type MessageHandler = (data: any) => void;
type RealtimeEvent = {
type: string;
[key: string]: unknown;
};
type MessageHandler = (data: RealtimeEvent) => void;
type ConnectionStatus = {
isConnected: boolean;
isConnecting: boolean;
reconnectAttempts: number;
};
type StatusHandler = (status: ConnectionStatus) => void;
class WebSocketManager {
private static instance: WebSocketManager | null = null;
@ -12,7 +22,9 @@ class WebSocketManager {
private reconnectAttempts = 0;
private maxReconnectAttempts = 5;
private messageHandlers: Set<MessageHandler> = new Set();
private statusHandlers: Set<StatusHandler> = new Set();
private isConnecting = false;
private shouldReconnect = false;
private constructor() {}
@ -23,6 +35,29 @@ class WebSocketManager {
return WebSocketManager.instance;
}
private hasSubscribers(): boolean {
return this.messageHandlers.size > 0 || this.statusHandlers.size > 0;
}
private getConnectionStatus(): ConnectionStatus {
return {
isConnected: this.ws?.readyState === WebSocket.OPEN,
isConnecting: this.isConnecting,
reconnectAttempts: this.reconnectAttempts,
};
}
private notifyStatus() {
const status = this.getConnectionStatus();
this.statusHandlers.forEach((handler) => {
try {
handler(status);
} catch (error) {
console.error('[WS Manager] Status handler error:', error);
}
});
}
connect() {
// Если уже подключены или подключаемся, не создаем новое соединение
if (this.ws?.readyState === WebSocket.OPEN || this.isConnecting) {
@ -30,13 +65,26 @@ class WebSocketManager {
return;
}
// Проверяем что мы в браузере и пользователь авторизован
if (typeof window === 'undefined' || !localStorage.getItem('access_token')) {
console.log('[WS Manager] Not in browser or not authenticated');
if (!this.hasSubscribers()) {
this.shouldReconnect = false;
return;
}
// Проверяем что мы в браузере и пользователь авторизован
if (typeof window === 'undefined' || !localStorage.getItem('access_token')) {
console.log('[WS Manager] Not in browser or not authenticated');
this.notifyStatus();
return;
}
if (this.reconnectTimeout) {
clearTimeout(this.reconnectTimeout);
this.reconnectTimeout = null;
}
this.shouldReconnect = true;
this.isConnecting = true;
this.notifyStatus();
try {
const backendUrl = process.env.NEXT_PUBLIC_BACKEND_URL || 'http://localhost:9000';
@ -61,6 +109,7 @@ class WebSocketManager {
console.log('[WS Manager] Connected');
this.isConnecting = false;
this.reconnectAttempts = 0;
this.notifyStatus();
};
this.ws.onmessage = (event) => {
@ -85,6 +134,7 @@ class WebSocketManager {
console.error('[WS Manager] WebSocket state:', this.ws?.readyState);
console.error('[WS Manager] WebSocket URL was:', this.ws?.url);
this.isConnecting = false;
this.notifyStatus();
};
this.ws.onclose = (event) => {
@ -94,10 +144,12 @@ class WebSocketManager {
console.log('[WS Manager] Close event wasClean:', event.wasClean);
this.isConnecting = false;
this.ws = null;
this.notifyStatus();
// Автоматический reconnect
if (this.reconnectAttempts < this.maxReconnectAttempts) {
if (this.shouldReconnect && this.hasSubscribers() && this.reconnectAttempts < this.maxReconnectAttempts) {
this.reconnectAttempts++;
this.notifyStatus();
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);
@ -106,10 +158,13 @@ class WebSocketManager {
} catch (error) {
console.error('[WS Manager] Connection failed:', error);
this.isConnecting = false;
this.notifyStatus();
}
}
disconnect() {
this.shouldReconnect = false;
if (this.reconnectTimeout) {
clearTimeout(this.reconnectTimeout);
this.reconnectTimeout = null;
@ -121,13 +176,16 @@ class WebSocketManager {
}
this.isConnecting = false;
this.reconnectAttempts = 0;
this.notifyStatus();
}
subscribe(handler: MessageHandler) {
this.messageHandlers.add(handler);
this.shouldReconnect = true;
// Если есть подписчик и нет соединения, подключаемся
if (this.messageHandlers.size > 0 && !this.ws && !this.isConnecting) {
if (this.hasSubscribers() && !this.ws && !this.isConnecting) {
this.connect();
}
@ -136,7 +194,26 @@ class WebSocketManager {
this.messageHandlers.delete(handler);
// Если больше нет подписчиков, отключаемся
if (this.messageHandlers.size === 0) {
if (!this.hasSubscribers()) {
console.log('[WS Manager] No more subscribers, disconnecting');
this.disconnect();
}
};
}
subscribeStatus(handler: StatusHandler) {
this.statusHandlers.add(handler);
handler(this.getConnectionStatus());
this.shouldReconnect = true;
if (this.hasSubscribers() && !this.ws && !this.isConnecting) {
this.connect();
}
return () => {
this.statusHandlers.delete(handler);
if (!this.hasSubscribers()) {
console.log('[WS Manager] No more subscribers, disconnecting');
this.disconnect();
}
@ -144,7 +221,7 @@ class WebSocketManager {
}
getState(): number {
return this.ws?.readyState ?? WebSocket.CLOSED;
return this.ws?.readyState ?? 3;
}
}