Refactor realtime sync and message UI
This commit is contained in:
parent
0833eb13ce
commit
613248905e
@ -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({
|
||||
|
||||
@ -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,
|
||||
|
||||
74
backend/notifications/realtime.py
Normal file
74
backend/notifications/realtime.py
Normal 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,
|
||||
},
|
||||
)
|
||||
@ -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):
|
||||
|
||||
108
backend/tests/api/v1/notifications/test_notifications_api.py
Normal file
108
backend/tests/api/v1/notifications/test_notifications_api.py
Normal 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": "Заявки"}
|
||||
@ -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
@ -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;
|
||||
|
||||
@ -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>
|
||||
|
||||
112
frontend/src/components/messages/ChatDialogHeader.tsx
Normal file
112
frontend/src/components/messages/ChatDialogHeader.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
41
frontend/src/components/messages/ChatMediaPreviewModal.tsx
Normal file
41
frontend/src/components/messages/ChatMediaPreviewModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
298
frontend/src/components/messages/ChatMessageItem.tsx
Normal file
298
frontend/src/components/messages/ChatMessageItem.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
100
frontend/src/components/messages/MessageActionsMenu.tsx
Normal file
100
frontend/src/components/messages/MessageActionsMenu.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
206
frontend/src/components/messages/MessageComposer.tsx
Normal file
206
frontend/src/components/messages/MessageComposer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
36
frontend/src/components/messages/ReactionPickerModal.tsx
Normal file
36
frontend/src/components/messages/ReactionPickerModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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);
|
||||
|
||||
71
frontend/src/hooks/useChatFallbackSync.ts
Normal file
71
frontend/src/hooks/useChatFallbackSync.ts
Normal 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]);
|
||||
}
|
||||
50
frontend/src/hooks/useSilentChatReloadGuard.ts
Normal file
50
frontend/src/hooks/useSilentChatReloadGuard.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@ -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(() => {
|
||||
|
||||
@ -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);
|
||||
|
||||
|
||||
124
frontend/src/lib/messages/chatUtils.ts
Normal file
124
frontend/src/lib/messages/chatUtils.ts
Normal 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());
|
||||
}
|
||||
102
frontend/src/lib/messages/messageUtils.ts
Normal file
102
frontend/src/lib/messages/messageUtils.ts
Normal 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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user