diff --git a/backend/api/apps.py b/backend/api/apps.py index 1ccbd768..612aa7e3 100644 --- a/backend/api/apps.py +++ b/backend/api/apps.py @@ -8,6 +8,4 @@ class ApiConfig(AppConfig): def ready(self): """Инициализация при загрузке приложения.""" - # Патч для django-scheduler теперь применяется в - # scheduling.apps.SchedulingConfig pass diff --git a/backend/api/docs_views.py b/backend/api/docs_views.py new file mode 100644 index 00000000..b76e52ca --- /dev/null +++ b/backend/api/docs_views.py @@ -0,0 +1,61 @@ +from pathlib import Path + +from django.conf import settings +from django.http import Http404, HttpResponse +from django.urls import reverse +from django.views.generic import TemplateView, View + + +ASYNCAPI_SCHEMA_PATH = ( + Path(settings.BASE_DIR) + / "docs" + / "architecture" + / "websocket-asyncapi.yaml" +) + + +def _load_asyncapi_schema_text() -> str: + try: + return ASYNCAPI_SCHEMA_PATH.read_text(encoding="utf-8") + except OSError as exc: + raise Http404("AsyncAPI schema file not found") from exc + + +class AsyncAPISchemaView(View): + """Отдаёт AsyncAPI схему WebSocket-контура отдельным endpoint.""" + + http_method_names = ["get"] + + def get(self, request, *args, **kwargs): + schema_text = _load_asyncapi_schema_text() + response = HttpResponse( + schema_text, + content_type="application/yaml; charset=utf-8", + ) + response["Content-Disposition"] = ( + 'inline; filename="websocket-asyncapi.yaml"' + ) + return response + + +class AsyncAPIDocsView(TemplateView): + """Простая HTML-страница с WS-документацией и raw AsyncAPI схемой.""" + + template_name = "api/websocket_docs.html" + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + ws_scheme = "wss" if self.request.is_secure() else "ws" + context.update( + { + "asyncapi_schema_url": reverse("api:ws-schema"), + "openapi_schema_url": reverse("api:schema"), + "swagger_url": reverse("api:swagger-ui"), + "redoc_url": reverse("api:redoc"), + "ws_endpoint_url": ( + f"{ws_scheme}://{self.request.get_host()}/ws/" + ), + "schema_text": _load_asyncapi_schema_text(), + } + ) + return context diff --git a/backend/api/urls.py b/backend/api/urls.py index fd706076..a151a685 100644 --- a/backend/api/urls.py +++ b/backend/api/urls.py @@ -9,6 +9,7 @@ from drf_spectacular.views import ( from rest_framework.permissions import AllowAny from .auth.views import JWTTokenRefreshView, PhoneOrEmailTokenObtainPairView +from .docs_views import AsyncAPIDocsView, AsyncAPISchemaView app_name = "api" urlpatterns = [ @@ -38,6 +39,8 @@ urlpatterns = [ ), name="redoc", ), + path("ws/schema/", AsyncAPISchemaView.as_view(), name="ws-schema"), + path("ws/docs/", AsyncAPIDocsView.as_view(), name="ws-docs"), path("v1/", include(("api.v1.urls", "v1"), namespace="v1")), path( "auth/token/", diff --git a/backend/api/v1/search/serializers.py b/backend/api/v1/search/serializers.py index 5ce1d924..47fee2e6 100644 --- a/backend/api/v1/search/serializers.py +++ b/backend/api/v1/search/serializers.py @@ -24,7 +24,6 @@ class SearchResultSerializer(serializers.Serializer): description = serializers.CharField( help_text="Описание/превью контента", allow_blank=True, required=False ) - url = serializers.CharField(help_text="Ссылка на объект", allow_blank=True) meta = serializers.DictField( help_text="Дополнительные метаданные", required=False, allow_null=True ) diff --git a/backend/api/v1/search/views.py b/backend/api/v1/search/views.py index 03ae6108..c177fcf8 100644 --- a/backend/api/v1/search/views.py +++ b/backend/api/v1/search/views.py @@ -53,7 +53,6 @@ def _format_employee(obj: Any) -> Dict[str, Any]: "description": getattr(obj.position, "name", "") if hasattr(obj, "position") else "", - "url": f"/employees/{obj.pk}/", "meta": { "email": obj.email, "phone": str(obj.phone_number) if obj.phone_number else "", @@ -68,7 +67,6 @@ def _format_department(obj: Any) -> Dict[str, Any]: "object_id": obj.pk, "title": obj.name, "description": obj.description or "", - "url": f"/departments/{obj.pk}/", "meta": { "head": str(obj.head) if hasattr(obj, "head") and obj.head else None }, @@ -84,7 +82,6 @@ def _format_post(obj: Any) -> Dict[str, Any]: "description": obj.body[:200] + "..." if len(obj.body) > 200 else obj.body, - "url": f"/feed/{obj.pk}/", "meta": { "author": str(obj.author) if hasattr(obj, "author") else None, "created_at": obj.created_at.isoformat() @@ -103,7 +100,6 @@ def _format_request(obj: Any) -> Dict[str, Any]: "description": obj.comment[:200] + "..." if obj.comment and len(obj.comment) > 200 else (obj.comment or ""), - "url": f"/requests/{obj.pk}/", "meta": { "status": obj.status, "type": obj.type, @@ -119,7 +115,6 @@ def _format_chat(obj: Any) -> Dict[str, Any]: "object_id": obj.pk, "title": obj.name or f"Чат #{obj.pk}", "description": obj.description or "", - "url": f"/messages?chat={obj.pk}", "meta": { "type": obj.type if hasattr(obj, "type") else None, }, @@ -137,14 +132,10 @@ def _format_message(obj: Any) -> Dict[str, Any]: "description": obj.content[:200] + "..." if len(obj.content) > 200 else obj.content, - "url": ( - f"/messages?chat={ - obj.chat.pk if hasattr(obj, 'chat') else '' - }#msg-{obj.pk}" - ), "meta": { "author": str(obj.author) if hasattr(obj, "author") else None, "chat": str(obj.chat) if hasattr(obj, "chat") else None, + "chat_id": obj.chat.pk if hasattr(obj, "chat") else None, }, } @@ -156,7 +147,6 @@ def _format_event(obj: Any) -> Dict[str, Any]: "object_id": obj.pk, "title": obj.title, "description": obj.description or "", - "url": f"/calendar?event={obj.pk}", "meta": { "start_date": obj.start_date.isoformat() if hasattr(obj, "start_date") @@ -172,7 +162,6 @@ def _format_schedule_event(obj: Any) -> Dict[str, Any]: "object_id": obj.pk, "title": obj.title, "description": obj.description or "", - "url": "/calendar/", "meta": { "start": obj.start.isoformat() if hasattr(obj, "start") else None, "end": obj.end.isoformat() @@ -191,7 +180,6 @@ def _format_procurement_request(obj: Any) -> Dict[str, Any]: "description": obj.description[:200] + "..." if len(obj.description) > 200 else obj.description, - "url": f"/procurement/requests/{obj.pk}/", "meta": { "status": obj.status if hasattr(obj, "status") else None, "requestor": str(obj.requestor) @@ -215,7 +203,6 @@ def _format_equipment(obj: Any) -> Dict[str, Any]: if obj.serial_number else f"Инв. №: {obj.inventory_number}" ), - "url": f"/procurement/equipment/{obj.pk}/", "meta": { "inventory_number": obj.inventory_number, "status": obj.status if hasattr(obj, "status") else None, @@ -235,7 +222,6 @@ def _format_document(obj: Any) -> Dict[str, Any]: if obj.description and len(obj.description) > 200 else (obj.description or "") ), - "url": f"/documents/{obj.pk}/", "meta": { "uploaded_by": str(obj.uploaded_by) if hasattr(obj, "uploaded_by") and obj.uploaded_by @@ -247,23 +233,34 @@ def _format_document(obj: Any) -> Dict[str, Any]: } +def _get_notification_search_title(obj: Any) -> str: + """Строит заголовок уведомления для поиска без legacy-зависимостей.""" + data = getattr(obj, "data", None) or {} + if isinstance(data, dict) and data.get("title"): + return str(data["title"]) + + verb = getattr(obj, "verb", "") or "notification" + return str(verb).replace("_", " ").title() + + +def _get_notification_search_message(obj: Any) -> str: + """Возвращает текст уведомления для результатов поиска.""" + return getattr(obj, "description", "") or "" + + def _format_notification(obj: Any) -> Dict[str, Any]: """Форматирует уведомление для API.""" + message = _get_notification_search_message(obj) + return { "model_name": "notification", "object_id": obj.pk, - "title": obj.title, - "description": obj.short_message - or ( - obj.message[:150] + "..." if len(obj.message) > 150 else obj.message - ), - "url": obj.action_url - if hasattr(obj, "action_url") and obj.action_url - else "/notifications/", + "title": _get_notification_search_title(obj), + "description": message[:150] + "..." if len(message) > 150 else message, "meta": { - "is_read": obj.is_read if hasattr(obj, "is_read") else False, - "created_at": obj.created_at.isoformat() - if hasattr(obj, "created_at") + "is_read": not getattr(obj, "unread", True), + "created_at": obj.timestamp.isoformat() + if hasattr(obj, "timestamp") and obj.timestamp else None, }, } @@ -296,7 +293,6 @@ def _format_result(obj: Any, model_name: str) -> Dict[str, Any]: "object_id": obj.pk, "title": str(obj), "description": "", - "url": "#", "meta": {}, } @@ -339,7 +335,6 @@ def search_api_view(request: DRFRequest) -> Response: "object_id": 123, "title": "Заголовок", "description": "Описание", - "url": "/path/to/object/", "meta": {...} }, ... diff --git a/backend/communications/api/serializers.py b/backend/communications/api/serializers.py index e7e0f5f0..2e773110 100644 --- a/backend/communications/api/serializers.py +++ b/backend/communications/api/serializers.py @@ -134,12 +134,20 @@ class ChatListSerializer(serializers.ModelSerializer): @extend_schema_field(ChatLastMessageSerializer) def get_last_message(self, obj): - """Последнее сообщение в чате""" - last_msg = ( - obj.messages.filter(is_deleted=False) - .order_by("-created_at") - .first() - ) + """Последнее сообщение в чате (использует prefetch если доступен)""" + last_msg = None + if ( + hasattr(obj, '_prefetched_last_message') + and obj._prefetched_last_message + ): + last_msg = obj._prefetched_last_message[0] + else: + last_msg = ( + obj.messages.filter(is_deleted=False) + .select_related('author') + .order_by("-created_at") + .first() + ) if last_msg: return { "id": last_msg.id, @@ -153,12 +161,11 @@ class ChatListSerializer(serializers.ModelSerializer): @extend_schema_field(serializers.ListField(child=serializers.CharField())) def get_participant_names(self, obj): - """Имена участников (для приватных чатов)""" + """Имена участников (для приватных чатов, из prefetch)""" if obj.type == "private": - active_members = obj.memberships.filter( - is_active=True - ).select_related("user")[:5] - return [m.user.get_full_name() for m in active_members] + # memberships уже prefetch'нуты в ChatViewSet.get_queryset + members = list(obj.memberships.all())[:5] + return [m.user.get_full_name() for m in members] return [] @extend_schema_field( diff --git a/backend/communications/api/viewsets.py b/backend/communications/api/viewsets.py index b7beb286..a73413ca 100644 --- a/backend/communications/api/viewsets.py +++ b/backend/communications/api/viewsets.py @@ -72,6 +72,40 @@ def _parse_bool_query_param(value, default=True): return default +def _build_message_search_snippet(message, query, max_length=140): + query_normalized = (query or '').strip().lower() + content = (message.content or '').strip() + + if content: + if not query_normalized: + return f"{content[:max_length].rstrip()}…" if len(content) > max_length else content + + content_normalized = content.lower() + match_index = content_normalized.find(query_normalized) + + if match_index < 0: + return f"{content[:max_length].rstrip()}…" if len(content) > max_length else content + + context_before = max_length // 3 + context_after = max_length - context_before - len(query_normalized) + start = max(0, match_index - context_before) + end = min(len(content), match_index + len(query_normalized) + context_after) + snippet = content[start:end].strip() + + if start > 0: + snippet = f"…{snippet}" + if end < len(content): + snippet = f"{snippet}…" + return snippet + + attachment_names = [a.file_name for a in getattr(message, 'attachments').all() if a.file_name] + if attachment_names: + joined_names = ', '.join(attachment_names) + return f"Файлы: {joined_names}" + + return 'Сообщение без текста' + + class ChatViewSet(viewsets.ModelViewSet): """ ViewSet для управления чатами @@ -116,7 +150,6 @@ class ChatViewSet(viewsets.ModelViewSet): queryset = Chat.objects.filter( Q(memberships__user=user, memberships__is_active=True) | Q(participants=user) - | Q(participants=user) | Q(include_all_users=True) | Q(created_by=user) # Создатель всегда видит свои чаты ).select_related( @@ -139,7 +172,15 @@ class ChatViewSet(viewsets.ModelViewSet): 'read_states', queryset=ChatReadState.objects.filter(user=user), to_attr='my_read_state' - ) + ), + # Prefetch последнее сообщение чата (1 запрос вместо N) + Prefetch( + 'messages', + queryset=Message.objects.filter( + is_deleted=False, + ).select_related('author').order_by('-created_at')[:1], + to_attr='_prefetched_last_message' + ), ).distinct() # Исключаем чаты-комментарии из общего списка @@ -562,14 +603,27 @@ class ChatViewSet(viewsets.ModelViewSet): self._send_marked_read_event(user.id, chat.id, last_message.id) def _send_marked_read_event(self, user_id, chat_id, message_id): - """WebSocket событие для синхронизации между вкладками""" + """WebSocket событие для синхронизации и read receipts.""" channel_layer = get_channel_layer() + event_payload = { + 'chat_id': chat_id, + 'last_read_message_id': message_id, + 'reader_user_id': user_id, + } + async_to_sync(channel_layer.group_send)( f'user_{user_id}', + { + 'type': 'chat_marked_read_sync', + **event_payload, + } + ) + + async_to_sync(channel_layer.group_send)( + f'chat_{chat_id}', { 'type': 'chat_marked_read', - 'chat_id': chat_id, - 'last_read_message_id': message_id + **event_payload, } ) @@ -607,7 +661,14 @@ class ChatViewSet(viewsets.ModelViewSet): queryset = chat.messages.filter(is_deleted=False).select_related( 'author', 'reply_to', 'reply_to__author', 'poll' ).prefetch_related( - 'attachments', 'reactions', 'reactions__user', 'poll__options' + 'attachments', + 'reactions', + 'reactions__user', + 'poll__options', + Prefetch( + 'chat__read_states', + queryset=ChatReadState.objects.select_related('user'), + ), ) # Определяем порядок сортировки в зависимости от типа запроса @@ -706,7 +767,14 @@ class ChatViewSet(viewsets.ModelViewSet): queryset = chat.messages.filter(is_deleted=False).select_related( 'author', 'reply_to', 'reply_to__author', 'poll' ).prefetch_related( - 'attachments', 'reactions', 'reactions__user', 'poll__options' + 'attachments', + 'reactions', + 'reactions__user', + 'poll__options', + Prefetch( + 'chat__read_states', + queryset=ChatReadState.objects.select_related('user'), + ), ) # Если нет around_id, пытаемся получить last_read_message_id @@ -821,6 +889,79 @@ class ChatViewSet(viewsets.ModelViewSet): 'has_more_after': len(after) > after_limit }) + @action(detail=True, methods=['get'], url_path='search-messages') + def search_messages(self, request, pk=None): + """Поиск сообщений внутри одного чата с пагинацией и сниппетами.""" + chat = self.get_object() + + if not user_can_access_chat(chat, request.user): + return Response( + {'error': 'Access denied'}, + status=status.HTTP_403_FORBIDDEN + ) + + query = (request.query_params.get('q') or '').strip() + + try: + limit = min(max(int(request.query_params.get('limit', 20)), 1), 100) + except (ValueError, TypeError): + limit = 20 + + try: + offset = max(int(request.query_params.get('offset', 0)), 0) + except (ValueError, TypeError): + offset = 0 + + if len(query) < 2: + return Response({ + 'query': query, + 'count': 0, + 'offset': offset, + 'next_offset': None, + 'results': [], + }) + + queryset = chat.messages.filter( + is_deleted=False, + ).select_related( + 'author' + ).prefetch_related( + 'attachments' + ).filter( + Q(content__icontains=query) | Q(attachments__file_name__icontains=query) + ).distinct().order_by('-created_at', '-id') + + total_count = queryset.count() + matches = list(queryset[offset:offset + limit]) + next_offset = offset + limit if (offset + limit) < total_count else None + + results = [] + for message in matches: + author = message.author + author_name = ( + f"{getattr(author, 'last_name', '')} {getattr(author, 'first_name', '')}".strip() + or getattr(author, 'username', '') + or 'Сотрудник' + ) + attachments = list(message.attachments.all()) + results.append({ + 'message_id': message.id, + 'content': message.content or '', + 'snippet': _build_message_search_snippet(message, query), + 'author_name': author_name, + 'created_at': message.created_at, + 'attachments_count': len(attachments), + 'has_attachments': bool(attachments), + }) + + return Response({ + 'query': query, + 'count': total_count, + 'offset': offset, + 'next_offset': next_offset, + 'results': results, + }) + @action(detail=True, methods=['post'], url_path='mark-read') def mark_read(self, request, pk=None): """ @@ -1058,9 +1199,15 @@ class MessageViewSet(viewsets.ModelViewSet): # Перезагружаем с prefetch message = Message.objects.select_related( - 'author', 'reply_to', 'reply_to__author', 'poll' + 'author', 'reply_to', 'reply_to__author', 'poll', 'chat' ).prefetch_related( - 'attachments', 'reactions', 'reactions__user' + 'attachments', + 'reactions', + 'reactions__user', + Prefetch( + 'chat__read_states', + queryset=ChatReadState.objects.select_related('user'), + ), ).get(pk=message.id) # Отправляем через WebSocket @@ -1215,9 +1362,16 @@ class MessageViewSet(viewsets.ModelViewSet): # Перезагружаем с нуля чтобы получить обновленные attachments # Используем новый запрос, чтобы избежать кэширования instance = Message.objects.select_related( - 'author', 'reply_to', 'reply_to__author', 'poll' + 'author', 'reply_to', 'reply_to__author', 'poll', 'chat' ).prefetch_related( - 'attachments', 'reactions', 'reactions__user', 'poll__options' + 'attachments', + 'reactions', + 'reactions__user', + 'poll__options', + Prefetch( + 'chat__read_states', + queryset=ChatReadState.objects.select_related('user'), + ), ).get(pk=instance.id) # WebSocket уведомление diff --git a/backend/communications/consumers.py b/backend/communications/consumers.py index 7b2a1ba2..7c9314bd 100644 --- a/backend/communications/consumers.py +++ b/backend/communications/consumers.py @@ -13,10 +13,10 @@ WebSocket Consumer Mixin для чатов. import logging from channels.db import database_sync_to_async -from django.db.models import Q +from django.db.models import Prefetch, Q from django.utils import timezone -from .models import Chat, ChatMembership, Message, MessageReaction +from .models import Chat, ChatMembership, ChatReadState, Message, MessageReaction from .serialization import serialize_message logger = logging.getLogger(__name__) @@ -299,17 +299,21 @@ class ChatConsumerMixin: } ) - async def chat_marked_read(self, event): - """Синхронизация отметки прочитанного между вкладками. - - Telegram-style. - """ + async def _send_marked_read_to_client(self, event, *, allow_self: bool): chat_id = event.get("chat_id") last_read_message_id = event.get("last_read_message_id") + reader_user_id = event.get("reader_user_id") + + if chat_id != self.active_chat_id: + return + + if not allow_self and reader_user_id == getattr(self.user, "id", None): + return logger.info( f"[ChatMixin.chat_marked_read] Sending to client: " - f"chat={chat_id}, last_read_message_id={last_read_message_id}" + f"chat={chat_id}, last_read_message_id={last_read_message_id}, " + f"reader_user_id={reader_user_id}" ) await self.send_json( @@ -317,9 +321,18 @@ class ChatConsumerMixin: "type": "marked_read", "chat_id": chat_id, "last_read_message_id": last_read_message_id, + "reader_user_id": reader_user_id, } ) + async def chat_marked_read(self, event): + """Read receipt для других участников активного чата.""" + await self._send_marked_read_to_client(event, allow_self=False) + + async def chat_marked_read_sync(self, event): + """Синхронизация отметки прочитанного между вкладками читателя.""" + await self._send_marked_read_to_client(event, allow_self=True) + # ==================== Обработка действий пользователя ==================== async def _handle_send_message(self, content): @@ -710,8 +723,15 @@ class ChatConsumerMixin: """Получить последние сообщения чата""" messages = ( Message.objects.filter(chat_id=chat_id) - .select_related("author") - .prefetch_related("attachments", "reactions") + .select_related("author", "chat") + .prefetch_related( + "attachments", + "reactions", + Prefetch( + "chat__read_states", + queryset=ChatReadState.objects.select_related("user"), + ), + ) .order_by("-created_at")[:limit] ) diff --git a/backend/communications/serialization.py b/backend/communications/serialization.py index f1f2467f..bc7d8d73 100644 --- a/backend/communications/serialization.py +++ b/backend/communications/serialization.py @@ -48,6 +48,100 @@ def _get_author_url(author) -> str: return "" +def _is_message_read(m) -> bool: + """True, если сообщение прочитал хотя бы один другой участник.""" + chat = getattr(m, "chat", None) + if ( + not chat + or not getattr(m, "id", None) + or not getattr(m, "author_id", None) + ): + return False + + prefetched = getattr(chat, "_prefetched_objects_cache", {}) + read_states = prefetched.get("read_states") + if read_states is not None: + return any( + state.user_id != m.author_id + and state.last_read_message_id + and state.last_read_message_id >= m.id + for state in read_states + ) + + return chat.read_states.exclude(user_id=m.author_id).filter( + last_read_message_id__gte=m.id + ).exists() + + +def _get_user_avatar_url(user) -> str: + if not user: + return "" + + try: + if getattr(user, "avatar", None) and user.avatar: + return user.avatar.url + except Exception: + return "" + + return "" + + +def _serialize_reply_preview(reply_msg) -> dict: + is_deleted = bool(getattr(reply_msg, "is_deleted", False)) + + return { + "id": reply_msg.id, + "content": reply_msg.content[:100] if reply_msg.content else "", + "author_name": ( + reply_msg.author.get_full_name() + if reply_msg.author + else "Неизвестный" + ), + "is_deleted": is_deleted, + "has_attachments": bool(getattr(reply_msg, "has_attachments", False)), + } + + +def _get_message_read_by(m) -> list[dict]: + """Список участников, которые дочитали сообщение.""" + chat = getattr(m, "chat", None) + if ( + not chat + or not getattr(m, "id", None) + or not getattr(m, "author_id", None) + ): + return [] + + prefetched = getattr(chat, "_prefetched_objects_cache", {}) + read_states = prefetched.get("read_states") + if read_states is None: + read_states = chat.read_states.select_related("user").filter( + last_read_message_id__gte=m.id + ) + + readers = [] + for state in read_states: + if state.user_id == m.author_id: + continue + if not state.last_read_message_id or state.last_read_message_id < m.id: + continue + + user = getattr(state, "user", None) + if not user: + continue + + readers.append( + { + "id": user.id, + "name": user.get_full_name() or user.username, + "avatar": _get_user_avatar_url(user), + } + ) + + readers.sort(key=lambda item: item["name"].lower()) + return readers + + def serialize_message(m) -> dict: """ Сериализация сообщения с поддержкой всех полей. @@ -63,12 +157,7 @@ def serialize_message(m) -> dict: """ author = m.author author_name = author.get_full_name() or author.username - avatar = "" - try: - if getattr(author, "avatar", None) and author.avatar: - avatar = author.avatar.url - except Exception: - avatar = "" + avatar = _get_user_avatar_url(author) # Базовые поля data = { @@ -83,12 +172,15 @@ def serialize_message(m) -> dict: # Статусные поля "is_edited": m.is_edited, "edited_at": m.edited_at.isoformat() if m.edited_at else None, + "is_read": _is_message_read(m), + "read_by": _get_message_read_by(m), "is_deleted": m.is_deleted, "is_pinned": m.is_pinned, "is_forwarded": m.is_forwarded, "is_system": m.is_system, "has_attachments": m.has_attachments, } + data["read_count"] = len(data["read_by"]) # Информация о пересылке (используем forward_metadata) if m.is_forwarded: @@ -205,17 +297,7 @@ def serialize_message(m) -> dict: pk=m.reply_to_id ) - data["reply_to"] = { - "id": reply_msg.id, - "content": ( - reply_msg.content[:100] if reply_msg.content else "" - ), - "author_name": ( - reply_msg.author.get_full_name() - if reply_msg.author - else "Неизвестный" - ), - } + data["reply_to"] = _serialize_reply_preview(reply_msg) except Exception: # Если не удалось загрузить reply_to, просто пропускаем pass diff --git a/backend/docs/architecture/websocket-asyncapi.yaml b/backend/docs/architecture/websocket-asyncapi.yaml new file mode 100644 index 00000000..c94b7619 --- /dev/null +++ b/backend/docs/architecture/websocket-asyncapi.yaml @@ -0,0 +1,560 @@ +asyncapi: 2.6.0 +info: + title: EUSRR Realtime WebSocket API + version: 1.0.0 + description: | + Отдельная AsyncAPI-спецификация для WebSocket-контура EUSRR. + + HTTP API продолжает документироваться через OpenAPI в /api/schema/. + Этот документ описывает только двусторонний realtime-канал /ws/. + + Аутентификация: + - query-параметр ?token= + - или заголовок Authorization: Bearer + +defaultContentType: application/json +servers: + development: + url: localhost:9000/ws/ + protocol: ws + description: Локальная разработка + production: + url: api.example.com/ws/ + protocol: wss + description: Продакшен через TLS +channels: + userRealtime: + address: /ws/ + description: | + Универсальное пользовательское соединение для всех real-time сценариев: + - сообщения и список чатов + - уведомления и unread count + - typing indicators + - реакции и голосования + - procurement updates + publish: + operationId: clientAction + summary: Команды от клиента к серверу + message: + oneOf: + - $ref: '#/components/messages/OpenChatAction' + - $ref: '#/components/messages/CloseChatAction' + - $ref: '#/components/messages/SendMessageAction' + - $ref: '#/components/messages/EditMessageAction' + - $ref: '#/components/messages/DeleteMessageAction' + - $ref: '#/components/messages/AddReactionAction' + - $ref: '#/components/messages/RemoveReactionAction' + - $ref: '#/components/messages/TypingAction' + - $ref: '#/components/messages/StopTypingAction' + - $ref: '#/components/messages/VotePollAction' + - $ref: '#/components/messages/MarkReadAction' + subscribe: + operationId: serverEvent + summary: События от сервера клиенту + message: + oneOf: + - $ref: '#/components/messages/PingEvent' + - $ref: '#/components/messages/ErrorEvent' + - $ref: '#/components/messages/ChatOpenedEvent' + - $ref: '#/components/messages/ChatClosedEvent' + - $ref: '#/components/messages/InitialMessagesEvent' + - $ref: '#/components/messages/NewMessageEvent' + - $ref: '#/components/messages/ListUpdateEvent' + - $ref: '#/components/messages/MessageUpdatedEvent' + - $ref: '#/components/messages/MessageDeletedEvent' + - $ref: '#/components/messages/ReactionAddedEvent' + - $ref: '#/components/messages/ReactionRemovedEvent' + - $ref: '#/components/messages/TypingStartEvent' + - $ref: '#/components/messages/TypingStopEvent' + - $ref: '#/components/messages/PollUpdateEvent' + - $ref: '#/components/messages/MarkedReadEvent' + - $ref: '#/components/messages/NotificationEvent' + - $ref: '#/components/messages/UnreadCountEvent' + - $ref: '#/components/messages/NotificationReadEvent' + - $ref: '#/components/messages/NotificationsReadAllEvent' + - $ref: '#/components/messages/ProcurementUpdateEvent' +components: + schemas: + ChatId: + type: integer + minimum: 1 + MessageId: + type: integer + minimum: 1 + UserId: + type: integer + minimum: 1 + Emoji: + type: string + minLength: 1 + GenericMessagePayload: + type: object + description: | + Сериализованное сообщение чата. Полный набор полей зависит от + communications.serialization.serialize_message. + properties: + id: + type: integer + content: + type: string + nullable: true + chat: + type: integer + author: + type: integer + created_at: + type: string + format: date-time + required: + - id + additionalProperties: true + NotificationPayload: + type: object + description: Сериализованное уведомление пользователя. + properties: + id: + type: integer + verb: + type: string + description: + type: string + unread: + type: boolean + action_url: + type: string + nullable: true + data: + type: object + additionalProperties: true + required: + - id + - verb + - unread + additionalProperties: true + messages: + OpenChatAction: + name: OpenChatAction + title: Открыть чат + payload: + type: object + required: [action, chat_id] + properties: + action: + type: string + enum: [open_chat] + chat_id: + $ref: '#/components/schemas/ChatId' + load_history: + type: boolean + default: false + CloseChatAction: + name: CloseChatAction + title: Закрыть чат + payload: + type: object + required: [action, chat_id] + properties: + action: + type: string + enum: [close_chat] + chat_id: + $ref: '#/components/schemas/ChatId' + SendMessageAction: + name: SendMessageAction + title: Отправить сообщение + payload: + type: object + required: [action] + properties: + action: + type: string + enum: [send_message] + content: + type: string + text: + type: string + EditMessageAction: + name: EditMessageAction + title: Редактировать сообщение + payload: + type: object + required: [action, message_id, content] + properties: + action: + type: string + enum: [edit_message] + message_id: + $ref: '#/components/schemas/MessageId' + content: + type: string + DeleteMessageAction: + name: DeleteMessageAction + title: Удалить сообщение + payload: + type: object + required: [action, message_id] + properties: + action: + type: string + enum: [delete_message] + message_id: + $ref: '#/components/schemas/MessageId' + AddReactionAction: + name: AddReactionAction + title: Добавить реакцию + payload: + type: object + required: [action, message_id, emoji] + properties: + action: + type: string + enum: [add_reaction] + message_id: + $ref: '#/components/schemas/MessageId' + emoji: + $ref: '#/components/schemas/Emoji' + RemoveReactionAction: + name: RemoveReactionAction + title: Удалить реакцию + payload: + type: object + required: [action, message_id, emoji] + properties: + action: + type: string + enum: [remove_reaction] + message_id: + $ref: '#/components/schemas/MessageId' + emoji: + $ref: '#/components/schemas/Emoji' + TypingAction: + name: TypingAction + title: Пользователь печатает + payload: + type: object + required: [action] + properties: + action: + type: string + enum: [typing] + StopTypingAction: + name: StopTypingAction + title: Пользователь перестал печатать + payload: + type: object + required: [action] + properties: + action: + type: string + enum: [stop_typing] + VotePollAction: + name: VotePollAction + title: Голосование в опросе + payload: + type: object + required: [action] + properties: + action: + type: string + enum: [vote_poll] + additionalProperties: true + MarkReadAction: + name: MarkReadAction + title: Deprecated mark_read + payload: + type: object + required: [action, chat_id] + properties: + action: + type: string + enum: [mark_read] + chat_id: + $ref: '#/components/schemas/ChatId' + note: + type: string + example: Deprecated, используйте HTTP mark-read endpoint + PingEvent: + name: PingEvent + payload: + type: object + required: [type, timestamp] + properties: + type: + type: string + enum: [ping] + timestamp: + type: string + format: date-time + ErrorEvent: + name: ErrorEvent + payload: + type: object + required: [type, error] + properties: + type: + type: string + enum: [error] + error: + type: string + chat_id: + $ref: '#/components/schemas/ChatId' + ChatOpenedEvent: + name: ChatOpenedEvent + payload: + type: object + required: [type, chat_id] + properties: + type: + type: string + enum: [chat_opened] + chat_id: + $ref: '#/components/schemas/ChatId' + ChatClosedEvent: + name: ChatClosedEvent + payload: + type: object + required: [type, chat_id] + properties: + type: + type: string + enum: [chat_closed] + chat_id: + $ref: '#/components/schemas/ChatId' + InitialMessagesEvent: + name: InitialMessagesEvent + payload: + type: object + required: [type, chat_id, messages] + properties: + type: + type: string + enum: [initial_messages] + chat_id: + $ref: '#/components/schemas/ChatId' + messages: + type: array + items: + $ref: '#/components/schemas/GenericMessagePayload' + NewMessageEvent: + name: NewMessageEvent + payload: + type: object + required: [type, chat_id, message] + properties: + type: + type: string + enum: [new_message] + chat_id: + $ref: '#/components/schemas/ChatId' + message: + $ref: '#/components/schemas/GenericMessagePayload' + ListUpdateEvent: + name: ListUpdateEvent + payload: + type: object + required: [type, chat_id, message] + properties: + type: + type: string + enum: [list_update] + chat_id: + $ref: '#/components/schemas/ChatId' + message: + $ref: '#/components/schemas/GenericMessagePayload' + MessageUpdatedEvent: + name: MessageUpdatedEvent + payload: + type: object + required: [type, chat_id, message] + properties: + type: + type: string + enum: [message_updated] + chat_id: + $ref: '#/components/schemas/ChatId' + message: + $ref: '#/components/schemas/GenericMessagePayload' + MessageDeletedEvent: + name: MessageDeletedEvent + payload: + type: object + required: [type, chat_id, message_id] + properties: + type: + type: string + enum: [message_deleted] + chat_id: + $ref: '#/components/schemas/ChatId' + message_id: + $ref: '#/components/schemas/MessageId' + ReactionAddedEvent: + name: ReactionAddedEvent + payload: + type: object + required: [type, chat_id, message_id, emoji, user_id] + properties: + type: + type: string + enum: [reaction_added] + chat_id: + $ref: '#/components/schemas/ChatId' + message_id: + $ref: '#/components/schemas/MessageId' + emoji: + $ref: '#/components/schemas/Emoji' + user_id: + $ref: '#/components/schemas/UserId' + user_name: + type: string + reactions_summary: + type: object + additionalProperties: true + ReactionRemovedEvent: + name: ReactionRemovedEvent + payload: + type: object + required: [type, chat_id, message_id, emoji, user_id] + properties: + type: + type: string + enum: [reaction_removed] + chat_id: + $ref: '#/components/schemas/ChatId' + message_id: + $ref: '#/components/schemas/MessageId' + emoji: + $ref: '#/components/schemas/Emoji' + user_id: + $ref: '#/components/schemas/UserId' + reactions_summary: + type: object + additionalProperties: true + TypingStartEvent: + name: TypingStartEvent + payload: + type: object + required: [type, chat_id, user_id] + properties: + type: + type: string + enum: [typing_start] + chat_id: + $ref: '#/components/schemas/ChatId' + user_id: + $ref: '#/components/schemas/UserId' + user_name: + type: string + TypingStopEvent: + name: TypingStopEvent + payload: + type: object + required: [type, chat_id, user_id] + properties: + type: + type: string + enum: [typing_stop] + chat_id: + $ref: '#/components/schemas/ChatId' + user_id: + $ref: '#/components/schemas/UserId' + PollUpdateEvent: + name: PollUpdateEvent + payload: + type: object + required: [type, chat_id] + properties: + type: + type: string + enum: [poll_update] + chat_id: + $ref: '#/components/schemas/ChatId' + poll_id: + type: integer + message_id: + $ref: '#/components/schemas/MessageId' + results: + type: object + additionalProperties: true + MarkedReadEvent: + name: MarkedReadEvent + payload: + type: object + required: [type, chat_id, last_read_message_id] + properties: + type: + type: string + enum: [marked_read] + chat_id: + $ref: '#/components/schemas/ChatId' + last_read_message_id: + $ref: '#/components/schemas/MessageId' + NotificationEvent: + name: NotificationEvent + payload: + type: object + required: [type, notification] + properties: + type: + type: string + enum: [notification] + notification: + $ref: '#/components/schemas/NotificationPayload' + UnreadCountEvent: + name: UnreadCountEvent + payload: + type: object + required: [type, count] + properties: + type: + type: string + enum: [unread_count] + count: + type: integer + minimum: 0 + NotificationReadEvent: + name: NotificationReadEvent + payload: + type: object + required: [type, notification_id, unread_count] + properties: + type: + type: string + enum: [notification_read] + notification_id: + type: integer + unread_count: + type: integer + minimum: 0 + NotificationsReadAllEvent: + name: NotificationsReadAllEvent + payload: + type: object + required: [type, notification_ids, unread_count] + properties: + type: + type: string + enum: [notifications_read_all] + notification_ids: + type: array + items: + type: integer + category: + type: string + nullable: true + unread_count: + type: integer + minimum: 0 + ProcurementUpdateEvent: + name: ProcurementUpdateEvent + payload: + type: object + required: [type, event, data] + properties: + type: + type: string + enum: [procurement_update] + event: + type: string + data: + type: object + additionalProperties: true diff --git a/backend/notifications/api/views.py b/backend/notifications/api/views.py index c35b787a..a434b453 100644 --- a/backend/notifications/api/views.py +++ b/backend/notifications/api/views.py @@ -1,6 +1,7 @@ """ API endpoints для системы уведомлений v2.0 + Новая архитектура: - Notification с GenericForeignKey (actor/action_object/target) - verb вместо category/type @@ -35,16 +36,6 @@ from .serializers import ( ) -def _get_notification_title(notification): - """Получить заголовок уведомления из data или сгенерировать fallback""" - # Приоритет: title из data (задается при создании уведомления) - if notification.data and 'title' in notification.data: - return notification.data['title'] - - # Fallback: генерируем простой заголовок из verb - return notification.verb.replace('_', ' ').title() - - def _format_time_or_none(value): if value is None: return None @@ -126,7 +117,11 @@ def get_notifications(request): { 'id': n.id, # Frontend ожидает title, message, is_read, created_at - 'title': _get_notification_title(n), + 'title': ( + n.data['title'] + if n.data and 'title' in n.data + else n.verb.replace('_', ' ').title() + ), 'message': n.description, 'short_message': ( n.description[:100] + '...' diff --git a/backend/templates/api/websocket_docs.html b/backend/templates/api/websocket_docs.html new file mode 100644 index 00000000..806a60aa --- /dev/null +++ b/backend/templates/api/websocket_docs.html @@ -0,0 +1,137 @@ + + + + + + EUSRR WebSocket AsyncAPI + + + +
+
+

WebSocket AsyncAPI

+

+ Отдельная документация realtime-контура EUSRR. HTTP API продолжает + жить в OpenAPI, а WebSocket-контур документируется отдельно через + AsyncAPI, без смешивания двух протоколов в одной схеме. +

+ +
+ +
+
+

Точка подключения

+

{{ ws_endpoint_url }}

+

+ Аутентификация поддерживает либо query-параметр + ?token=<jwt_access_token>, либо заголовок + Authorization: Bearer <jwt_access_token>. +

+
+
+

Что описано

+

+ В схеме отражены клиентские action-сообщения и серверные + realtime-события для чатов, уведомлений, typing, реакций, + read-sync и procurement updates. +

+
+
+

Почему отдельно

+

+ Swagger и ReDoc используют OpenAPI-схему HTTP API. Для + WebSocket-протокола отдельный AsyncAPI-документ чище, + понятнее и лучше масштабируется. +

+
+
+ +
+

Содержимое AsyncAPI

+
{{ schema_text }}
+
+
+ + diff --git a/backend/tests/api/test_documentation.py b/backend/tests/api/test_documentation.py index 7b52cc95..088c8f00 100644 --- a/backend/tests/api/test_documentation.py +++ b/backend/tests/api/test_documentation.py @@ -51,3 +51,27 @@ def test_swagger_and_redoc_pages_are_available(api_client: APIClient): assert "swagger-ui" in swagger_response.content.decode().lower() assert "redoc" in redoc_response.content.decode().lower() + + +def test_websocket_asyncapi_schema_is_available(api_client: APIClient): + response = api_client.get(reverse("api:ws-schema")) + + assert response.status_code == 200 + assert "application/yaml" in response["Content-Type"] + + schema = response.content.decode() + assert "asyncapi: 2.6.0" in schema + assert "address: /ws/" in schema + assert "OpenChatAction" in schema + assert "NotificationEvent" in schema + + +def test_websocket_asyncapi_docs_page_is_available(api_client: APIClient): + response = api_client.get(reverse("api:ws-docs")) + + assert response.status_code == 200 + + html = response.content.decode().lower() + assert "websocket asyncapi" in html + assert reverse("api:ws-schema") in response.content.decode() + assert "/ws/" in response.content.decode() diff --git a/backend/tests/api/v1/communications/test_communications_api.py b/backend/tests/api/v1/communications/test_communications_api.py index 5d301502..8e033ddb 100644 --- a/backend/tests/api/v1/communications/test_communications_api.py +++ b/backend/tests/api/v1/communications/test_communications_api.py @@ -3,7 +3,7 @@ """ import pytest -from communications.models import Chat, ChatMembership, ChatUserSettings, Message +from communications.models import Chat, ChatMembership, ChatReadState, ChatUserSettings, Message from django.contrib.auth import get_user_model from django.urls import reverse from employees.models import Department @@ -201,6 +201,39 @@ class TestChatViewSet: assert "messages" in response.data assert len(response.data["messages"]) == 5 + def test_chat_messages_include_read_by_users(self, auth_client, private_chat, user1, user2): + """Payload сообщения содержит список пользователей, которые его прочитали.""" + user2.avatar = "avatars/read-by-user2.jpg" + user2.save(update_fields=["avatar"]) + + message = Message.objects.create( + chat=private_chat, + author=user1, + content="Read me", + ) + read_state = ChatReadState.objects.get( + chat=private_chat, + user=user2, + ) + read_state.last_read_message = message + read_state.unread_count = 0 + read_state.save(update_fields=["last_read_message", "unread_count", "updated_at"]) + + url = f"/api/v1/communications/chats/{private_chat.pk}/messages/" + response = auth_client.get(url) + + assert response.status_code == status.HTTP_200_OK + payload = response.data["messages"][0] + assert payload["is_read"] is True + assert payload["read_count"] == 1 + assert payload["read_by"] == [ + { + "id": user2.id, + "name": user2.get_full_name(), + "avatar": user2.avatar.url, + } + ] + def test_mark_read(self, auth_client, private_chat, user1): """Пометка чата как прочитанного""" url = f"/api/v1/communications/chats/{private_chat.pk}/mark-read/" @@ -2418,6 +2451,35 @@ class TestDeleteVariousMessageTypes: assert reply2.is_deleted is False assert reply1.reply_to_id == original.id + def test_messages_around_keeps_deleted_reply_preview_context_contract( + self, auth_client, private_chat, user1 + ): + """Ответ на удаленное сообщение сохраняет контекст preview и помечается как deleted.""" + original = Message.objects.create( + chat=private_chat, author=user1, content="Original to delete" + ) + reply = Message.objects.create( + chat=private_chat, author=user1, content="Reply", reply_to=original + ) + + delete_url = f"/api/v1/communications/messages/{original.pk}/" + delete_response = auth_client.delete(delete_url) + + assert delete_response.status_code == status.HTTP_204_NO_CONTENT + + url = f"/api/v1/communications/chats/{private_chat.pk}/messages-around/" + response = auth_client.get(url, {"around_id": reply.id}) + + assert response.status_code == status.HTTP_200_OK + serialized_reply = next( + item for item in response.data["messages"] if item["id"] == reply.id + ) + + assert serialized_reply["reply_to"]["id"] == original.id + assert serialized_reply["reply_to"]["is_deleted"] is True + assert serialized_reply["reply_to"]["content"] == "Original to delete" + assert "author_name" in serialized_reply["reply_to"] + def test_bulk_delete_various_types(self, auth_client, private_chat, user1): """Массовое удаление сообщений разных типов""" from communications.models import MessageAttachment, Poll, PollOption diff --git a/backend/tests/api/v1/test_search.py b/backend/tests/api/v1/test_search.py index 13174733..713da9d4 100644 --- a/backend/tests/api/v1/test_search.py +++ b/backend/tests/api/v1/test_search.py @@ -10,6 +10,7 @@ from rest_framework.test import APIClient from employees.models import Employee, Department, Position from feed.models import Post +from notifications.models import Notification from requests_app.models import Request @@ -251,7 +252,7 @@ class TestSearchAPI: assert 'model_name' in result assert 'object_id' in result assert 'title' in result - assert 'url' in result + assert 'url' not in result def test_search_request_permissions(self, api_client, sample_data): """Пользователи видят только свои заявления.""" @@ -296,6 +297,30 @@ class TestSearchAPI: posts = [r for r in response.data['results'] if r['model_name'] == 'post'] assert len(posts) > 0 + def test_search_formats_notification_v2_without_500(self, api_client, regular_user): + """Поиск по уведомлениям v2 не падает на legacy-ожиданиях формата.""" + from django.core.management import call_command + + Notification.objects.create( + recipient=regular_user, + verb="system_notice", + description="Это обычный текст уведомления для поиска", + data={"title": "Важное уведомление"}, + action_url="/documents/5/", + ) + call_command('buildwatson', verbosity=0) + + response = api_client.get('/api/v1/search/?q=обычный') + + assert response.status_code == status.HTTP_200_OK + notifications = [ + r for r in response.data['results'] if r['model_name'] == 'notification' + ] + assert len(notifications) > 0 + assert notifications[0]['title'] == 'Важное уведомление' + assert 'обычный текст уведомления' in notifications[0]['description'].lower() + assert 'url' not in notifications[0] + @pytest.mark.django_db class TestSearchIndexing: diff --git a/docs/reports/CHAT_REFACTORING_ANALYSIS.md b/docs/reports/CHAT_REFACTORING_ANALYSIS.md new file mode 100644 index 00000000..965ab3f9 --- /dev/null +++ b/docs/reports/CHAT_REFACTORING_ANALYSIS.md @@ -0,0 +1,150 @@ +# Отчёт: Рефакторинг чатов EUSRR + Сравнительный анализ + +## 1. Что было сделано + +### 1.1 Frontend — Декомпозиция монолита `page.tsx` + +| Метрика | До | После | +|---|---|---| +| Строк в `page.tsx` | **1 989** | **893** (−55%) | +| `useState` в `page.tsx` | 40+ | 18 (UI-only) | +| `useEffect` в `page.tsx` | 15+ | 10 | +| `useCallback` в `page.tsx` | 20+ | ~12 | +| Выделенные хуки | 0 | 3 новых | + +**Новые файлы:** + +- `hooks/useMarkRead.ts` — дебаунсированная отметка прочтения с очередью, watermark-синхронизация. +- `hooks/useChatMessages.ts` — состояние сообщений, пагинация, pending/optimistic, unread-трекинг, навигация по дате, возврат к непрочитанным. +- `hooks/useChatScroll.ts` — управление скроллом, floating date, кнопка «вниз», определение позиции. + +### 1.2 Исправление бага со счётчиком непрочитанных + +**Причина:** `flushMarkRead` после `markChatAsRead` вызывал `getChat(chatId)` и перезаписывал `newMessagesBelowCount` серверным значением (0), из-за чего бейдж исчезал мгновенно. + +**Решение:** `onReadAcknowledged` в `useMarkRead` теперь **не трогает** локальный счётчик. Сброс `newMessagesBelowCount` происходит **только** когда пользователь физически доскроллит до низа (`syncScrollToBottomState`). Серверный `chat.unread_count` обновляется отдельно через `setChat(fresh)`. + +### 1.3 Backend — N+1 оптимизации + +| Проблема | Файл | Решение | +|---|---|---| +| `get_last_message` делал N запросов (1 на чат) | `serializers.py` | Добавлен `Prefetch('messages', ...)` с `to_attr='_prefetched_last_message'` в `ChatViewSet.get_queryset` | +| `get_participant_names` перезапрашивал memberships | `serializers.py` | Теперь использует уже prefetch'нутый `obj.memberships.all()` | +| Дублирующийся `Q(participants=user)` | `viewsets.py` | Удалён дубль | + +**Уже было оптимизировано (не трогали):** +- `reactions__user` — prefetch в `MessageViewSet.get_queryset` ✓ +- `ChatReadState.unread_count` — денормализовано через сигнал ✓ +- `react`/`unreact` — уже используют `select_related('user')` ✓ + +--- + +## 2. Оценка нагрузки на БД + +### 2.1 Список чатов (`GET /chats/`) + +| Ресурс | Запросов (до) | Запросов (после) | +|---|---|---| +| Основной запрос чатов | 1 | 1 | +| Prefetch memberships | 1 | 1 | +| Prefetch user_settings | 1 | 1 | +| Prefetch read_states | 1 | 1 | +| **last_message** | **N** (1 на чат) | **1** (prefetch) | +| **participant_names** | **N** (1 на чат) | **0** (из prefetch) | +| **ИТОГО для 50 чатов** | **~104** | **~5** | + +### 2.2 Страница чата (`GET /chats/{id}/`, `GET /messages/`) + +| Операция | Запросов | +|---|---| +| Загрузка чата | 5 (chat + 4 prefetch) | +| Загрузка 30 сообщений вокруг anchor | 1 + 4 prefetch = 5 | +| WebSocket new_message | 0 (из памяти) | +| mark-read (дебаунс 300ms) | 1 POST + 1 GET (refresh chat) | +| Реакция | 1 POST + 1 SELECT (summary) | +| **Типичное открытие чата** | **~12 запросов** | + +### 2.3 Узкие места (остаются) + +1. **`get_last_message` в retrieve** — для единичного `getChat()` prefetch не помогает (1 запрос). Допустимо. +2. **`PollViewSet.vote`** — `option.votes.count()` в цикле (N+1). Нужен `annotate(Count('votes'))`. +3. **`ChatViewSet.create`** — `User.objects.get(id=user_id)` в цикле при добавлении участников. Нужен `User.objects.filter(id__in=...)`. + +--- + +## 3. Сравнение с популярными мессенджерами + +### 3.1 Архитектура доставки сообщений + +| Фича | Telegram | WhatsApp | Slack | **EUSRR** | +|---|---|---|---|---| +| Транспорт | MTProto (custom) | Signal Protocol | WebSocket | **WebSocket** | +| Оффлайн-очередь | Push + pull при connect | Push + pull | Pull при connect | **Pull при connect** (fallback sync) | +| E2E шифрование | Secret Chats only | Да | Enterprise only | **Нет** (корп. сеть) | +| Оптимистичные сообщения | Да | Да | Да | **Да** ✓ | +| Pending → Delayed → Failed | Да (часы/галочки) | Да | Нет | **Да** ✓ | + +### 3.2 Управление прочтением + +| Фича | Telegram | WhatsApp | Slack | **EUSRR** | +|---|---|---|---|---| +| Подсчёт непрочитанных | Денормализован | Денормализован | Денормализован (badge API) | **Денормализован** (`ChatReadState.unread_count`) ✓ | +| Mark-as-read | При открытии (2с задержка) | При открытии | При фокусе канала | **При скролле до сообщения** (300ms debounce) | +| Двойные галочки (read receipt) | Да | Да | Нет | **Да** (`is_read`, `read_by`) ✓ | +| Кто прочитал | Нет (кроме групп Premium) | Нет | Hover-подсказка | **Модальное окно** ✓ | + +### 3.3 Пагинация и скролл + +| Фича | Telegram | WhatsApp | Slack | **EUSRR** | +|---|---|---|---|---| +| Пагинация | Бикурсорная (around + offset) | Local-first DB (SQLite) | Cursor-based | **Бикурсорная** (`before_id`/`after_id`/`around_id`) ✓ | +| Навигация по дате | Да (календарь) | Да (jump to date) | Нет | **Да** ✓ | +| Scroll restoration | position-to-bottom | Anchor-based | Виртуализация | **Anchor-based** (`getSnapshotBeforeUpdate`) ✓ | +| Виртуализация списка | Нет (native) | Нет (native) | Да | **Нет** ⚠️ | + +### 3.4 Реакции + +| Фича | Telegram | WhatsApp | Slack | **EUSRR** | +|---|---|---|---|---| +| Быстрые реакции | 5 недавних + picker | 6 недавних + picker | Несколько + picker | **5 недавних + 40 emoji** ✓ | +| Custom emoji | Premium | Нет | Workspace emoji | **Нет** | +| Реакция-нотификация | Push | Push | Push | **WebSocket** ✓ | + +### 3.5 Что есть у конкурентов, но нет у EUSRR + +| Фича | Приоритет | Сложность | +|---|---|---| +| **Виртуализация списка сообщений** (react-window) | Высокий (>1000 сообщений = тормоза) | Средняя | +| **Реал-тайм обновление списка чатов** (unread badge без перезагрузки) | Высокий | Низкая (WS уже есть) | +| **Threads** (ветки обсуждений) | Средний | Высокая | +| **Поиск по сообщениям** | Средний | Средняя | +| **Голосовые сообщения** | Низкий | Средняя | +| **Пересланные сообщения (UI)** | Низкий | Низкая | + +### 3.6 Что у EUSRR лучше аналогов + +1. **Система ролей в чатах** (admin/moderator/member/guest) — нет у Telegram/WhatsApp в таком виде. +2. **Интеграция с оргструктурой** — чаты отделов, привязка к LDAP. +3. **Контекстные чаты** (комментарии к постам/документам) — нет у классических мессенджеров. +4. **Управление доступом** — гости без права отправки, модерация контента. + +--- + +## 4. Рекомендации по дальнейшим улучшениям + +### Высокий приоритет + +1. **Виртуализация списка сообщений** — при >500 сообщений DOM замедляется. Использовать `@tanstack/react-virtual` или `react-window`. +2. **WebSocket обновление списка чатов** — `wsManager` уже существует; нужен подписчик на `new_message` для обновления `last_message` и `unread_count` в списке без перезагрузки. +3. **Annotate poll vote counts** — `PollViewSet.vote` имеет N+1 на `option.votes.count()`. Заменить на `annotate(Count('votes'))`. + +### Средний приоритет + +4. **State management** — рассмотреть Zustand или TanStack Query для кеширования чатов/сообщений между страницами. +5. **Bulk create memberships** — `ChatViewSet.create` делает N запросов при добавлении участников. +6. **Поиск по сообщениям** — PostgreSQL full-text search (`SearchVector`, `SearchQuery`). + +### Низкий приоритет + +7. Удалить дублирование WebSocket логики (`useWebSocket` vs `wsManager`). +8. Typed API client вместо `any` в `createMessagesApi`. diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 19ff695e..3004a395 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -18,6 +18,7 @@ "@types/react-avatar-editor": "^13.0.4", "@types/xlsx": "^0.0.35", "date-fns": "^4.1.0", + "emoji-picker-react": "^4.18.0", "fuse.js": "^7.1.0", "lucide-react": "^0.574.0", "mammoth": "^1.11.0", @@ -4028,6 +4029,21 @@ "integrity": "sha512-9tfDXhJ4RKFNerfjdCcZfufu49vg620741MNs26a9+bhLThdB+plgMeou98CAaHu/WATj2iHOOHTp1hWtABj2A==", "dev": true }, + "node_modules/emoji-picker-react": { + "version": "4.18.0", + "resolved": "https://registry.npmjs.org/emoji-picker-react/-/emoji-picker-react-4.18.0.tgz", + "integrity": "sha512-vLTrLfApXAIciguGE57pXPWs9lPLBspbEpPMiUq03TIli2dHZBiB+aZ0R9/Wat0xmTfcd4AuEzQgSYxEZ8C88Q==", + "license": "MIT", + "dependencies": { + "flairup": "1.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "react": ">=16" + } + }, "node_modules/emoji-regex": { "version": "9.2.2", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", @@ -4769,6 +4785,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/flairup": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/flairup/-/flairup-1.0.0.tgz", + "integrity": "sha512-IKlE+pNvL2R+kVL1kEhUYqRxVqeFnjiIvHWDMLFXNaqyUdFXQM2wte44EfMYJNHkW16X991t2Zg8apKkhv7OBA==", + "license": "MIT" + }, "node_modules/flat-cache": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index b3d7f064..f1c0cbbd 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -20,6 +20,7 @@ "@types/react-avatar-editor": "^13.0.4", "@types/xlsx": "^0.0.35", "date-fns": "^4.1.0", + "emoji-picker-react": "^4.18.0", "fuse.js": "^7.1.0", "lucide-react": "^0.574.0", "mammoth": "^1.11.0", diff --git a/frontend/src/app/documents/page.tsx b/frontend/src/app/documents/page.tsx index 55550bb6..1fc36772 100644 --- a/frontend/src/app/documents/page.tsx +++ b/frontend/src/app/documents/page.tsx @@ -4,6 +4,7 @@ import { AppShell } from "../../components/AppShell"; import { apiClient } from "@/lib/api"; import { useEffect, useMemo, useState } from "react"; import dynamic from "next/dynamic"; +import { usePathname, useRouter, useSearchParams } from "next/navigation"; import type { Document } from "@/types/api"; import { Search, @@ -62,6 +63,9 @@ function formatDate(value?: string): string { } export default function DocumentsPage() { + const router = useRouter(); + const pathname = usePathname(); + const searchParams = useSearchParams(); // State const [documents, setDocuments] = useState([]); const [folders, setFolders] = useState([]); @@ -95,6 +99,19 @@ export default function DocumentsPage() { // Bulk selection const selection = useDocumentSelection(documents); + const linkedDocumentId = Number(searchParams.get("document") || ""); + + const clearDocumentParam = () => { + if (!searchParams.get("document")) return; + const nextParams = new URLSearchParams(searchParams.toString()); + nextParams.delete("document"); + router.replace(nextParams.toString() ? `${pathname}?${nextParams.toString()}` : pathname, { scroll: false }); + }; + + const closeSelectedDocument = () => { + setSelectedDocument(null); + clearDocumentParam(); + }; const loadDocuments = async () => { try { @@ -143,6 +160,26 @@ export default function DocumentsPage() { loadTags(); }, []); + useEffect(() => { + if (!linkedDocumentId || selectedDocument?.id === linkedDocumentId) return; + + let cancelled = false; + + apiClient.getDocument(linkedDocumentId) + .then((document) => { + if (!cancelled) { + setSelectedDocument(document); + } + }) + .catch((error) => { + console.error("Ошибка deep-link документа:", error); + }); + + return () => { + cancelled = true; + }; + }, [linkedDocumentId, selectedDocument?.id]); + const filteredDocuments = useMemo(() => { const q = search.trim().toLowerCase(); @@ -813,7 +850,7 @@ export default function DocumentsPage() { setSelectedDocument(null)} + onClose={closeSelectedDocument} onUpdate={() => { loadDocuments(); if (selectedDocument) { diff --git a/frontend/src/app/employees/page.tsx b/frontend/src/app/employees/page.tsx index 25e580cc..a326e45f 100644 --- a/frontend/src/app/employees/page.tsx +++ b/frontend/src/app/employees/page.tsx @@ -69,8 +69,7 @@ export default function EmployeesPage() { setCreatingChatFor(employee.id); try { // Ищем существующий приватный чат с этим пользователем - const chatsResponse = await apiClient.getChats(); - const allChats = chatsResponse.results || chatsResponse; + const allChats = await apiClient.getAllChats(); const existingChat = allChats.find((chat: any) => { if (chat.type !== 'private') return false; diff --git a/frontend/src/app/equipment/page.tsx b/frontend/src/app/equipment/page.tsx index 3753c32a..aab1775a 100644 --- a/frontend/src/app/equipment/page.tsx +++ b/frontend/src/app/equipment/page.tsx @@ -7,6 +7,7 @@ import { Archive, ArrowRightLeft, ArrowUpDown, ChevronDown, Filter, MessageSquar import { SearchableSelectSingle } from "@/components/shared/SearchableSelect"; import { formatDate, formatMoney } from "@/lib/shared"; import { useEquipmentPage } from "@/hooks/useEquipmentPage"; +import { Modal } from "@/components/ui"; const listModeMeta = [ { value: "all", label: "Весь реестр" }, @@ -611,14 +612,14 @@ export default function EquipmentPage() { )} {/* ===== Modal create/edit ===== */} - {isModalOpen && ( -
{ if (e.target === e.currentTarget) closeModal(); }}> -
-
-

{modalMode === "create" ? "Добавить оборудование" : "Редактировать оборудование"}

- + + +
- + }> {actionError &&

{actionError}

}
@@ -740,30 +741,20 @@ export default function EquipmentPage() { />
+ - {/* Buttons */} -
- - + + + {operationModal === "transfer" && } + {operationModal === "writeoff" && } + {operationModal === "maintenance" && }
-
- - )} - - {operationModal && selectedEquipment && ( -
{ if (e.target === e.currentTarget) closeOperationModal(); }}> -
-
-

- {operationModal === "transfer" && "Перевод оборудования"} - {operationModal === "writeoff" && "Списание оборудования"} - {operationModal === "maintenance" && "Добавить обслуживание"} -

- -
- + }> {operationModal === "transfer" && (
)} - -
- - {operationModal === "transfer" && } - {operationModal === "writeoff" && } - {operationModal === "maintenance" && } -
-
-
- )} + {/* ===== Attachment preview removed (no attachment fields in model) ===== */} diff --git a/frontend/src/app/messages-chatscope/page.tsx b/frontend/src/app/messages-chatscope/page.tsx index 247532ba..d21cbc4b 100644 --- a/frontend/src/app/messages-chatscope/page.tsx +++ b/frontend/src/app/messages-chatscope/page.tsx @@ -92,8 +92,7 @@ export default function ChatscapeMessenger() { const loadChats = async () => { try { setLoading(true); - const response = await apiClient.getChats(); - const chatsList = response.results || []; + const chatsList = await apiClient.getAllChats(); setChats(chatsList); // Автоматически открываем первый чат @@ -114,8 +113,7 @@ export default function ChatscapeMessenger() { const handleChatCreated = async (chatId: number) => { try { // Перезагружаем список чатов - const response = await apiClient.getChats(); - const chatsList = response.results || []; + const chatsList = await apiClient.getAllChats(); setChats(chatsList); // Открываем новый чат diff --git a/frontend/src/app/messages/[chatId]/page.tsx b/frontend/src/app/messages/[chatId]/page.tsx index fa4a90c9..c65b899f 100644 --- a/frontend/src/app/messages/[chatId]/page.tsx +++ b/frontend/src/app/messages/[chatId]/page.tsx @@ -1,76 +1,140 @@ "use client"; import React, { useEffect, useLayoutEffect, useMemo, useRef, useState, useCallback } from "react"; -import { useParams } from "next/navigation"; -import { MessageCircle, ChevronDown } from "lucide-react"; +import { useParams, usePathname, useRouter, useSearchParams } from "next/navigation"; +import { MessageCircle, ChevronDown, ChevronLeft, ChevronRight } from "lucide-react"; import { AppShell } from "../../../components/AppShell"; import ChatDialogHeader from "@/components/messages/ChatDialogHeader"; import ChatMediaPreviewModal from "@/components/messages/ChatMediaPreviewModal"; import ChatMessageItem, { MediaPreview } from "@/components/messages/ChatMessageItem"; +import ChatSearchPanel from "@/components/messages/ChatSearchPanel"; import MessageActionsMenu from "@/components/messages/MessageActionsMenu"; +import MessageReadersModal from "@/components/messages/MessageReadersModal"; import MessageComposer from "@/components/messages/MessageComposer"; import ReactionPickerModal from "@/components/messages/ReactionPickerModal"; import { apiClient } from "@/lib/api"; -import type { Chat, Message } from "@/types/api"; +import type { ChatMessageSearchResponse, ChatMessageSearchResult, Message } from "@/types/api"; import { useUser } from "@/contexts/UserContext"; import { useChatFallbackSync } from "@/hooks/useChatFallbackSync"; import { useSilentChatReloadGuard } from "@/hooks/useSilentChatReloadGuard"; import { useWebSocket } from "@/hooks/useWebSocket"; -import { uniqueMessagesById } from "@/lib/messages/chatUtils"; -import { formatDayDivider, getMessagePreviewText, getReplyToId } from "@/lib/messages/messageUtils"; +import { useChatMessages } from "@/hooks/useChatMessages"; +import { useMarkRead } from "@/hooks/useMarkRead"; +import { useChatScroll, shiftDateInputValue } from "@/hooks/useChatScroll"; +import { mergeDisplayMessages, uniqueMessagesById } from "@/lib/messages/chatUtils"; +import { formatDayDivider, getMessageDate, getMessagePreviewText, getReplyToId } from "@/lib/messages/messageUtils"; import wsManager from "@/lib/websocketManager"; import ScrollableMessageList, { ScrollableMessageListInner } from "@/components/ScrollableMessageList"; -type ReplyTarget = { - id: number; - author: string; - preview: string; -}; +/* ─── types ─── */ -type MessageActionsAnchor = { - x: number; - y: number; -}; +type ReplyTarget = { id: number; author: string; preview: string }; +type MessageActionsAnchor = { x: number; y: number }; + +/* ─── constants ─── */ const RECENT_REACTIONS_KEY = "eusrr_recent_reactions"; const MAX_RECENT_REACTIONS = 5; const ALL_REACTIONS = [ - "👍", "❤️", "😂", "🔥", "👏", "🎉", "😊", "😉", "😁", "🤝", - "🙏", "😮", "😢", "😡", "💯", "✅", "👀", "🤔", "😍", "😎", - "🤩", "🥳", "😴", "🫡", "👌", "💪", "🙌", "🧠", "💡", "🚀", - "🎯", "⭐", "✨", "🌟", "🫶", "🤗", "😅", "🤯", "🥲", "🫠", + "👍","❤️","😂","🔥","👏","🎉","😊","😉","😁","🤝", + "🙏","😮","😢","😡","💯","✅","👀","🤔","😍","😎", + "🤩","🥳","😴","🫡","👌","💪","🙌","🧠","💡","🚀", + "🎯","⭐","✨","💩","🫶","🤗","😅","🤯","🥲","🫠", ]; +/* ─── utils ─── */ + function uniqueEmoji(items: string[]): string[] { const seen = new Set(); - const out: string[] = []; - - items.forEach((item) => { - if (!item || seen.has(item)) return; - seen.add(item); - out.push(item); - }); - - return out; + return items.filter(e => { if (!e || seen.has(e)) return false; seen.add(e); return true; }); } +function getMessageDayKey(message: Message): string | null { + const d = getMessageDate(message); + return d ? `${d.getFullYear()}-${d.getMonth()}-${d.getDate()}` : null; +} + +function formatDateInputValue(date: Date): string { + return `${date.getFullYear()}-${`${date.getMonth()+1}`.padStart(2,"0")}-${`${date.getDate()}`.padStart(2,"0")}`; +} + +/* ─── component ─── */ + export default function MessageDialogPage() { const params = useParams<{ chatId: string }>(); + const router = useRouter(); + const pathname = usePathname(); + const searchParams = useSearchParams(); const chatId = Number(params.chatId); const { user, loading: userLoading } = useUser(); const { handleReconnectExhausted, resetReloadGuard } = useSilentChatReloadGuard(chatId || null); - const [chat, setChat] = useState(null); - const [messages, setMessages] = useState([]); - const [loading, setLoading] = useState(true); - const [messagesLoading, setMessagesLoading] = useState(false); - const [error, setError] = useState(null); + /* ── refs ── */ + const messagesViewportRef = useRef(null); + const fileInputRef = useRef(null); + const messageInputRef = useRef(null); + const searchInputRef = useRef(null); + const highlightTimerRef = useRef(null); + + /* ────────────────────────────────────────────────────── + * HOOK: Chat messages (state, pagination, pending) + * ────────────────────────────────────────────────────── */ + const cm = useChatMessages({ chatId, userId: user?.id, userLoading }); + + /* ────────────────────────────────────────────────────── + * HOOK: Scroll behavior (floating date, button, etc.) + * ────────────────────────────────────────────────────── */ + const scroll = useChatScroll({ + chatId, + messageCount: cm.messages.length + cm.pendingMessages.length, + viewportRef: messagesViewportRef, + hasMoreNewerRef: cm.hasMoreNewerRef, + unreadBelowIdsRef: cm.unreadBelowIdsRef, + historyNavigationMode: cm.historyNavigationMode, + }); + + // Wire scroll helpers into the messages hook so it can use them + useEffect(() => { + cm.isNearBottomRef.current = scroll.isNearBottom; + cm.scrollToBottomRef.current = scroll.scrollToBottom; + }, [cm.isNearBottomRef, cm.scrollToBottomRef, scroll.isNearBottom, scroll.scrollToBottom]); + + /* ────────────────────────────────────────────────────── + * HOOK: Mark-as-read (debounced) + * ────────────────────────────────────────────────────── */ + const markRead = useMarkRead({ + chatId, + onReadAcknowledged: useCallback(() => { + // We intentionally do NOT pull fresh chat/unread_count here. + // The local newMessagesBelowCount resets only when the user + // physically scrolls to the bottom (via syncScrollToBottomState). + }, []), + }); + + // Sync confirmed watermark from chat.last_read_message_id + useEffect(() => { + markRead.syncConfirmed(cm.chat?.last_read_message_id ?? 0); + }, [cm.chat?.last_read_message_id, markRead]); + + /* ── derived state ── */ + const displayMessages = useMemo(() => mergeDisplayMessages(cm.messages, cm.pendingMessages), [cm.messages, cm.pendingMessages]); + const messagesById = useMemo(() => new Map(displayMessages.map(m => [m.id, m])), [displayMessages]); + + /** + * Badge count: take the maximum of the server-provided unread_count + * and locally tracked new-messages-below. This ensures the badge + * stays visible even after mark-read flushes (until user scrolls down). + */ + const displayedUnreadCount = Math.max(cm.chat?.unread_count ?? 0, cm.newMessagesBelowCount); + + /* ── UI state ── */ const [messageText, setMessageText] = useState(""); const [sending, setSending] = useState(false); const [editingMessageId, setEditingMessageId] = useState(null); const [replyTo, setReplyTo] = useState(null); const [expandedReplyActionForId, setExpandedReplyActionForId] = useState(null); const [actionsMenuAnchor, setActionsMenuAnchor] = useState<{ x: number; y: number } | null>(null); + const [isReadersModalOpen, setIsReadersModalOpen] = useState(false); const [reactionPickerForMessageId, setReactionPickerForMessageId] = useState(null); const [showComposerEmojiPicker, setShowComposerEmojiPicker] = useState(false); const [recentReactions, setRecentReactions] = useState(ALL_REACTIONS.slice(0, MAX_RECENT_REACTIONS)); @@ -78,396 +142,93 @@ export default function MessageDialogPage() { const [brokenMedia, setBrokenMedia] = useState>({}); const [useOriginalImage, setUseOriginalImage] = useState>({}); const [mediaPreview, setMediaPreview] = useState(null); - const [floatingDate, setFloatingDate] = useState(null); - const [showFloatingDate, setShowFloatingDate] = useState(false); - const [showScrollToBottom, setShowScrollToBottom] = useState(false); - const floatingDateTimeoutRef = useRef(null); - const fileInputRef = useRef(null); - const messageInputRef = useRef(null); - const messagesViewportRef = useRef(null); - const [hasMoreOlder, setHasMoreOlder] = useState(false); - const [hasMoreNewer, setHasMoreNewer] = useState(false); - const [loadingOlder, setLoadingOlder] = useState(false); - const [loadingNewer, setLoadingNewer] = useState(false); - const [initialAnchorId, setInitialAnchorId] = useState(null); const [isTyping, setIsTyping] = useState(false); const typingTimeoutRef = useRef(null); - const [initialAnchorIndex, setInitialAnchorIndex] = useState(null); - const [allowOneOlderProbe, setAllowOneOlderProbe] = useState(false); - const initialScrolledRef = useRef(false); - const anchorScrollAttemptsRef = useRef(0); - const anchorOlderLoadAttemptsRef = useRef(0); - const [anchorRetryTick, setAnchorRetryTick] = useState(0); - const hasMoreNewerRef = useRef(false); - const fallbackSyncInFlightRef = useRef(false); - - // Локальное состояние для настроек чата const [isPinned, setIsPinned] = useState(false); const [notificationsEnabled, setNotificationsEnabled] = useState(true); - - // Синхронизируем ref с state + const [jumpingToDate, setJumpingToDate] = useState(false); + const [isSearchOpen, setIsSearchOpen] = useState(false); + const [searchQuery, setSearchQuery] = useState(""); + const [searchResults, setSearchResults] = useState([]); + const [searchTotalCount, setSearchTotalCount] = useState(0); + const [selectedSearchResultIndex, setSelectedSearchResultIndex] = useState(0); + const [searchLoading, setSearchLoading] = useState(false); + const [searchLoadingMore, setSearchLoadingMore] = useState(false); + const [searchError, setSearchError] = useState(null); + const [nextSearchOffset, setNextSearchOffset] = useState(null); + const [highlightedMessageId, setHighlightedMessageId] = useState(null); + const linkedMessageId = Number(searchParams.get("message") || ""); + + // Sync settings from chat useEffect(() => { - hasMoreNewerRef.current = hasMoreNewer; - }, [hasMoreNewer]); - - // Синхронизация настроек чата - useEffect(() => { - if (chat) { - setIsPinned(chat.is_pinned ?? false); - setNotificationsEnabled(chat.notifications_enabled ?? true); + if (cm.chat) { + setIsPinned(cm.chat.is_pinned ?? false); + setNotificationsEnabled(cm.chat.notifications_enabled ?? true); } - }, [chat]); - - const messagesById = useMemo(() => new Map(messages.map((m) => [m.id, m])), [messages]); - - // Определяем текущий membership и права на отправку сообщений + }, [cm.chat]); + + useEffect(() => { + if (!isSearchOpen) { + return; + } + + requestAnimationFrame(() => { + searchInputRef.current?.focus(); + searchInputRef.current?.select(); + }); + }, [isSearchOpen]); + + /* ── membership & permissions ── */ const myMembership = useMemo(() => { - if (!chat?.memberships || !user?.id) return null; - return chat.memberships.find(m => m.user === user.id) || null; - }, [chat?.memberships, user?.id]); - + if (!cm.chat?.memberships || !user?.id) return null; + return cm.chat.memberships.find(m => m.user === user.id) || null; + }, [cm.chat?.memberships, user?.id]); + const canSendMessages = useMemo(() => { - // Если нет membership - разрешаем (обычные чаты без ролей) if (!myMembership) return true; - - // Проверяем флаг can_send_messages (гости имеют false) return myMembership.can_send_messages; }, [myMembership]); - + + /* ── action menu derived ── */ const selectedActionMessage = expandedReplyActionForId ? messagesById.get(expandedReplyActionForId) || null : null; const selectedActionCanManage = Boolean( - selectedActionMessage && - user?.id && - !selectedActionMessage.is_deleted && - (selectedActionMessage.author_id === user.id || - selectedActionMessage.author?.id === user.id || - selectedActionMessage.sender?.id === user.id) + selectedActionMessage && user?.id && !selectedActionMessage.is_deleted && + (selectedActionMessage.author_id === user.id || selectedActionMessage.author?.id === user.id || selectedActionMessage.sender?.id === user.id), ); const selectedActionCanReply = Boolean(selectedActionMessage && !selectedActionMessage.is_deleted); - const selectedActionRecentReactions = useMemo( - () => uniqueEmoji(recentReactions).slice(0, MAX_RECENT_REACTIONS), - [recentReactions] - ); - - // WebSocket для real-time обновлений - const { isConnected, sendTyping, handlers } = useWebSocket({ - chatId: chatId || null, - autoConnect: true, - onReconnectExhausted: handleReconnectExhausted, - }); + const selectedActionRecentReactions = useMemo(() => uniqueEmoji(recentReactions).slice(0, MAX_RECENT_REACTIONS), [recentReactions]); + const selectedActionReaders = selectedActionMessage?.read_by || []; + const sendingComposer = Boolean(editingMessageId && sending); + /* ── reactions ── */ useEffect(() => { - if (!chatId || Number.isNaN(chatId)) { - return; - } - - wsManager.setActiveChat(chatId); - - return () => { - wsManager.clearActiveChat(chatId); - }; - }, [chatId]); - - useEffect(() => { - if (!isConnected) { - return; - } - - resetReloadGuard(); - }, [isConnected, resetReloadGuard]); - - // Проверка, находится ли пользователь внизу списка - const isNearBottom = useCallback(() => { - const component = messagesViewportRef.current; - if (!component || !component.containerRef?.current) return false; - - const viewport = component.containerRef.current; - const threshold = 150; // px от низа - const { scrollHeight, scrollTop, clientHeight } = viewport; - return scrollHeight - scrollTop - clientHeight <= threshold; - }, []); - - // Автопрокрутка вниз - const scrollToBottom = useCallback((smooth = false) => { - const component = messagesViewportRef.current; - if (!component) return; - - component.scrollToBottom(smooth ? 'smooth' : 'auto'); - }, []); - - const syncLatestMessages = useCallback(async ( - options: { limit?: number; scrollOnSync?: boolean } = {} - ) => { - if ( - !chatId || - Number.isNaN(chatId) || - messagesLoading || - messages.length === 0 || - fallbackSyncInFlightRef.current - ) { - return; - } - - const newestMessage = messages[messages.length - 1]; - const shouldMarkRead = isNearBottom(); - - fallbackSyncInFlightRef.current = true; - - try { - const response = await apiClient.getChatMessages(chatId, { - limit: options.limit ?? 20, - after_id: newestMessage.id, - mark_read: shouldMarkRead, - }); - - const newerMessages = response.messages || []; - if (newerMessages.length > 0) { - setMessages((prev) => uniqueMessagesById([...prev, ...newerMessages])); - - if (shouldMarkRead) { - const lastLoadedMessage = newerMessages[newerMessages.length - 1]; - setChat((prev) => - prev - ? { - ...prev, - last_read_message_id: Math.max(prev.last_read_message_id ?? 0, lastLoadedMessage.id), - } - : prev - ); - } - - if (shouldMarkRead && options.scrollOnSync) { - requestAnimationFrame(() => { - scrollToBottom(false); - }); - } - } - - setHasMoreNewer(Boolean(response.has_more)); - } catch (e) { - console.error("Ошибка тихой синхронизации сообщений:", e); - } finally { - fallbackSyncInFlightRef.current = false; - } - }, [chatId, isNearBottom, messages, messagesLoading, scrollToBottom]); - - // Обновление reactions_summary для конкретного сообщения - const updateMessageReactionsSummary = useCallback(( - messageId: number, - summary?: Record - ) => { - if (!summary) return; - setMessages((prev) => prev.map((m) => (m.id === messageId ? { ...m, reactions_summary: summary } : m))); - }, []); - - // Настройка обработчиков WebSocket событий - useEffect(() => { - handlers.current.onMessage = (data) => { - if (data.type === 'new_message') { - // Новое сообщение - if (!data.message) { - return; - } - - const newMsg = data.message; - const isMyMessage = newMsg.author_id === user?.id; - const wasNearBottom = isNearBottom(); - - setMessages(prev => { - // Проверяем, нет ли уже такого сообщения (дедупликация) - if (prev.some(m => m.id === newMsg.id)) { - return prev; - } - - // ВАЖНО: Добавляем сообщение только если: - // 1. Это МОЕ сообщение (я только что отправил) - всегда добавляем - // 2. ИЛИ у нас загружены все сообщения (hasMoreNewerRef.current = false) - // 3. ИЛИ новое сообщение идет сразу после последнего (непрерывная последовательность) - - if (prev.length > 0 && !isMyMessage) { - const lastMessage = prev[prev.length - 1]; - const hasGap = newMsg.id - lastMessage.id > 1; // Есть пропущенные сообщения - - // Если есть разрыв И есть непрочитанные - НЕ добавляем - if (hasGap && hasMoreNewerRef.current) { - console.warn(`⚠️ WebSocket: пропуск сообщения ${newMsg.id} - есть незагруженные между ${lastMessage.id} и ${newMsg.id}`); - // Оставляем hasMoreNewer = true - return prev; - } - } - - // Добавляем сообщение - const updated = [...prev, newMsg]; - - // Сбрасываем флаг "есть еще новые сообщения" - setTimeout(() => { - setHasMoreNewer(false); - - // Автоотметка как прочитанное ТОЛЬКО если: - // 1. Это НЕ мое сообщение - // 2. И я был внизу (активно читал) - // Прагматичный подход: загружено = прочитано (для сообщений в области видимости) - if (!isMyMessage && wasNearBottom && chatId) { - apiClient.markChatAsRead(chatId, newMsg.id).catch(err => { - console.error('Ошибка автоотметки WebSocket сообщения:', err); - }); - } - - // Автопрокрутка вниз если: - // 1. Сообщение отправил я сам - // 2. Или я был внизу списка (читал последние сообщения) - if (isMyMessage || wasNearBottom) { - requestAnimationFrame(() => { - requestAnimationFrame(() => { - scrollToBottom(true); - }); - }); - } - }, 0); - - return updated; - }); - } - else if (data.type === 'message_edited') { - // Редактирование сообщения - if (!data.message) { - return; - } - - const editedMsg = data.message; - setMessages(prev => prev.map(m => - m.id === editedMsg.id ? { ...m, ...editedMsg } : m - )); - } - else if (data.type === 'message_deleted') { - // Удаление сообщения - setMessages(prev => prev.filter(m => m.id !== data.message_id)); - } - else if (data.type === 'typing_start') { - // Индикатор "печатает..." - if (data.user_id !== user?.id) { - setIsTyping(true); - - if (typingTimeoutRef.current) { - clearTimeout(typingTimeoutRef.current); - } - - typingTimeoutRef.current = setTimeout(() => { - setIsTyping(false); - }, 3000); - } - } - else if (data.type === 'typing_stop') { - // Остановка печати - if (data.user_id !== user?.id) { - setIsTyping(false); - if (typingTimeoutRef.current) { - clearTimeout(typingTimeoutRef.current); - } - } - } - else if (data.type === 'reaction_added') { - // Реакция добавлена - if (data.reactions_summary && typeof data.message_id === 'number') { - updateMessageReactionsSummary(data.message_id, data.reactions_summary); - } - } - else if (data.type === 'reaction_removed') { - // Реакция удалена - if (data.reactions_summary && typeof data.message_id === 'number') { - updateMessageReactionsSummary(data.message_id, data.reactions_summary); - } - } - else if (data.type === 'marked_read' && data.chat_id === chatId && typeof data.last_read_message_id === 'number') { - const lastReadMessageId = data.last_read_message_id; - - setChat((prev) => - prev - ? { - ...prev, - last_read_message_id: Math.max(prev.last_read_message_id ?? 0, lastReadMessageId), - } - : prev - ); - } - }; - - handlers.current.onConnect = () => { - void syncLatestMessages({ - limit: 30, - scrollOnSync: isNearBottom(), - }); - }; - - handlers.current.onDisconnect = () => { - // WebSocket отключен - }; - - handlers.current.onError = (error) => { - console.error('❌ WebSocket error:', error); - }; - }, [handlers, user?.id, chatId, isNearBottom, scrollToBottom, syncLatestMessages, updateMessageReactionsSummary]); - - // Очистка таймаута при размонтировании - useEffect(() => { - return () => { - if (typingTimeoutRef.current) { - clearTimeout(typingTimeoutRef.current); - } - if (floatingDateTimeoutRef.current) { - clearTimeout(floatingDateTimeoutRef.current); - } - }; - }, []); - - useLayoutEffect(() => { - if (typeof window === "undefined" || !window.history) return; - const prev = window.history.scrollRestoration; - window.history.scrollRestoration = "manual"; - return () => { - window.history.scrollRestoration = prev; - }; - }, []); - - useEffect(() => { - if (typeof window === "undefined") return; try { const raw = localStorage.getItem(RECENT_REACTIONS_KEY); if (!raw) return; const parsed = JSON.parse(raw); if (!Array.isArray(parsed)) return; - const normalized = uniqueEmoji(parsed.filter((v): v is string => typeof v === "string")).slice(0, MAX_RECENT_REACTIONS); - if (normalized.length > 0) { - setRecentReactions(normalized); - } - } catch { - // ignore - } + const norm = uniqueEmoji(parsed.filter((v): v is string => typeof v === "string")).slice(0, MAX_RECENT_REACTIONS); + if (norm.length > 0) setRecentReactions(norm); + } catch { /* ignore */ } }, []); const pushRecentReaction = (emoji: string) => { const next = uniqueEmoji([emoji, ...recentReactions]).slice(0, MAX_RECENT_REACTIONS); setRecentReactions(next); - if (typeof window !== "undefined") { - localStorage.setItem(RECENT_REACTIONS_KEY, JSON.stringify(next)); - } + if (typeof window !== "undefined") localStorage.setItem(RECENT_REACTIONS_KEY, JSON.stringify(next)); }; - const appendEmojiToComposer = (emoji: string) => { - setMessageText((prev) => `${prev}${emoji}`); - requestAnimationFrame(() => { - const input = messageInputRef.current; - if (!input) return; - input.focus(); - const len = input.value.length; - input.setSelectionRange(len, len); - }); - }; + const updateMessageReactionsSummary = useCallback(( + messageId: number, + summary?: Record, + ) => { + if (!summary) return; + cm.setMessages(prev => prev.map(m => m.id === messageId ? { ...m, reactions_summary: summary } : m)); + }, [cm]); const hasMyReaction = (message: Message, emoji: string): boolean => { if (!user?.id) return false; - const users = message.reactions_summary?.[emoji]?.users || []; - return users.includes(user.id); + return (message.reactions_summary?.[emoji]?.users || []).includes(user.id); }; const handleReact = async (message: Message, emoji: string) => { @@ -475,7 +236,6 @@ export default function MessageDialogPage() { const response = hasMyReaction(message, emoji) ? await apiClient.unreactToMessage(message.id, emoji) : await apiClient.reactToMessage(message.id, emoji); - pushRecentReaction(emoji); updateMessageReactionsSummary(message.id, response.reactions_summary); } catch (e) { @@ -483,484 +243,318 @@ export default function MessageDialogPage() { } }; - useEffect(() => { - async function loadChats() { - if (!chatId || Number.isNaN(chatId)) { - setChat(null); - setLoading(false); - return; - } - - // На full reload дожидаемся восстановления auth-состояния, - // иначе возможен первый 401 и «залипание» без повторной загрузки. - if (userLoading) { - setLoading(true); - return; - } - - if (!user) { - setChat(null); - setLoading(false); - return; - } - - try { - setLoading(true); - setError(null); - const loadedChat = await apiClient.getChat(chatId); - setChat(loadedChat); - } catch { - setChat(null); - setError("Не удалось загрузить чат. Проверьте подключение и попробуйте снова."); - } finally { - setLoading(false); - } - } - - loadChats(); - }, [chatId, userLoading, user, user?.id]); + /* ────────────────────────────────────────────────────── + * WebSocket + * ────────────────────────────────────────────────────── */ + const { isConnected, sendTyping, handlers } = useWebSocket({ + chatId: chatId || null, + autoConnect: true, + onReconnectExhausted: handleReconnectExhausted, + }); useEffect(() => { - async function loadMessages() { - if (!chatId || Number.isNaN(chatId)) { - setMessages([]); - setHasMoreOlder(false); - setHasMoreNewer(false); - setInitialAnchorId(null); - setInitialAnchorIndex(null); - setAllowOneOlderProbe(false); - initialScrolledRef.current = false; - return; - } + if (!chatId || Number.isNaN(chatId)) return; + wsManager.setActiveChat(chatId); + return () => { wsManager.clearActiveChat(chatId); }; + }, [chatId]); - // Ключевой фикс для полного reload: грузим сообщения только - // после завершения инициализации пользователя. - if (userLoading || !user) { - return; - } + useEffect(() => { if (isConnected) resetReloadGuard(); }, [isConnected, resetReloadGuard]); - try { - setMessagesLoading(true); - - // 1. Получаем детали чата с last_read_message_id - const chatDetails = await apiClient.getChat(chatId); - const lastReadId = chatDetails.last_read_message_id; - - // 2. Загружаем сообщения вокруг last_read_message_id - // Асимметричные лимиты: 24 контекста + 6 новых = 30 сообщений - // Автоотметка последнего загруженного как прочитанного - const around = await apiClient.getChatMessagesAround(chatId, { - limit: 30, // 24 до + 6 после - around_id: lastReadId || undefined + // WS event handler + useEffect(() => { + handlers.current.onMessage = (data) => { + if (data.type === "new_message" && data.message) { + const newMsg = data.message; + const isMyMessage = newMsg.author_id === user?.id; + const wasNearBottom = scroll.isNearBottom(); + cm.removePendingByServer(newMsg); + + if (!isMyMessage && !wasNearBottom) { + cm.registerNewMessagesBelow([newMsg]); + scroll.setShowScrollToBottom(true); + } + + cm.setMessages(prev => { + if (prev.some(m => m.id === newMsg.id)) return prev; + + if (prev.length > 0 && !isMyMessage) { + const last = prev[prev.length - 1]; + if (newMsg.id - last.id > 1 && cm.hasMoreNewerRef.current) return prev; + } + + const updated = [...prev, newMsg]; + + setTimeout(() => { + if (!isMyMessage && wasNearBottom && chatId) markRead.scheduleMarkRead(newMsg.id); + if (isMyMessage || wasNearBottom) { + requestAnimationFrame(() => requestAnimationFrame(() => scroll.scrollToBottom(true))); + } + }, 0); + + return updated; }); - - const aroundMessages = around.messages || []; + } else if (data.type === "message_edited" && data.message) { + const edited = data.message; + cm.setMessages(prev => prev.map(m => m.id === edited.id ? { ...m, ...edited } : m)); + } else if (data.type === "message_deleted") { + cm.setMessages(prev => prev.filter(m => m.id !== data.message_id)); + } else if (data.type === "typing_start" && data.user_id !== user?.id) { + setIsTyping(true); + if (typingTimeoutRef.current) clearTimeout(typingTimeoutRef.current); + typingTimeoutRef.current = setTimeout(() => setIsTyping(false), 3000); + } else if (data.type === "typing_stop" && data.user_id !== user?.id) { + setIsTyping(false); + if (typingTimeoutRef.current) clearTimeout(typingTimeoutRef.current); + } else if (data.type === "reaction_added" || data.type === "reaction_removed") { + if (data.reactions_summary && typeof data.message_id === "number") { + updateMessageReactionsSummary(data.message_id, data.reactions_summary); + } + } else if (data.type === "marked_read" && data.chat_id === chatId && typeof data.last_read_message_id === "number") { + const lastReadId = data.last_read_message_id; + const readerId = typeof data.reader_user_id === "number" ? data.reader_user_id : null; - if (aroundMessages.length > 0) { - const normalized = uniqueMessagesById(aroundMessages); - setMessages(normalized); - // fallback на случай, если API не вернул флаг has_more_before - setHasMoreOlder( - typeof around.has_more_before === "boolean" ? around.has_more_before : normalized.length >= 50 + if (readerId !== null) { + cm.setMessages(prev => + prev.map(m => { + const aId = m.author_id ?? m.author?.id ?? m.sender?.id; + if (m.id > lastReadId || aId === readerId || m.is_read) return m; + return { ...m, is_read: true }; + }), ); - setHasMoreNewer(Boolean(around.has_more_after)); - setInitialAnchorId(around.anchor_id ?? null); - setInitialAnchorIndex(typeof around.anchor_index === "number" ? around.anchor_index : null); - setAllowOneOlderProbe(Boolean(around.anchor_id)); - } else { - const response = await apiClient.getChatMessages(chatId, { limit: 50 }); - setMessages(uniqueMessagesById(response.messages || [])); - setHasMoreOlder(Boolean(response.has_more)); - setHasMoreNewer(false); - setInitialAnchorId(null); - setInitialAnchorIndex(null); - setAllowOneOlderProbe(false); } - initialScrolledRef.current = false; - anchorScrollAttemptsRef.current = 0; - anchorOlderLoadAttemptsRef.current = 0; - setAnchorRetryTick(0); - } catch (error: unknown) { - const errorMessage = error instanceof Error ? error.message : String(error); - if (errorMessage.includes("403")) { - setError("Нет доступа к этому чату"); - setMessages([]); - } else { - console.error("Ошибка загрузки сообщений:", error); + if (readerId === null || readerId === user?.id) { + markRead.syncConfirmed(lastReadId); + // Refresh chat to get updated unread_count, but do NOT reset local counter + void apiClient.getChat(chatId).then(fresh => cm.setChat(fresh)).catch(() => {}); } - setHasMoreOlder(false); - setHasMoreNewer(false); - setInitialAnchorId(null); - setInitialAnchorIndex(null); - setAllowOneOlderProbe(false); - initialScrolledRef.current = false; - anchorScrollAttemptsRef.current = 0; - anchorOlderLoadAttemptsRef.current = 0; - setAnchorRetryTick(0); - } finally { - setMessagesLoading(false); } - } + }; - loadMessages(); - }, [chatId, userLoading, user, user?.id]); + handlers.current.onConnect = () => { + void cm.syncLatestMessages({ limit: 30, scrollOnSync: scroll.isNearBottom() }); + }; + handlers.current.onDisconnect = () => {}; + handlers.current.onError = (err) => { console.error("❌ WebSocket error:", err); }; + }, [handlers, user?.id, chatId, scroll, cm, markRead, updateMessageReactionsSummary]); - // Скролл к якорю или вниз после загрузки сообщений - // Anchor scroll используется при переходе по прямой ссылке на сообщение (например, из уведомления) + /* ── cleanup timers ── */ + useEffect(() => () => { + if (typingTimeoutRef.current) clearTimeout(typingTimeoutRef.current); + if (highlightTimerRef.current) clearTimeout(highlightTimerRef.current); + }, []); + + useLayoutEffect(() => { + if (typeof window === "undefined" || !window.history) return; + const prev = window.history.scrollRestoration; + window.history.scrollRestoration = "manual"; + return () => { window.history.scrollRestoration = prev; }; + }, []); + + /* ── anchor scroll ── */ useEffect(() => { const component = messagesViewportRef.current; - if (messagesLoading || !component || !component.containerRef?.current || messages.length === 0 || initialScrolledRef.current) return; + if (cm.messagesLoading || !component?.containerRef?.current || displayMessages.length === 0 || !cm.isInitialScrollPending()) return; const viewport = component.containerRef.current; - // Если есть якорь - пытаемся к нему прокрутиться - if (initialAnchorId) { - const el = viewport.querySelector(`[data-message-id="${initialAnchorId}"]`) as HTMLElement | null; + if (cm.initialAnchorId) { + const el = viewport.querySelector(`[data-message-id="${cm.initialAnchorId}"]`) as HTMLElement | null; if (el) { - // Ручной scrollTop вместо scrollIntoView (iOS Safari не поддерживает scrollIntoView в overflow контейнерах) - const containerRect = viewport.getBoundingClientRect(); - const elRect = el.getBoundingClientRect(); - const offset = elRect.top - containerRect.top + viewport.scrollTop - viewport.clientHeight / 2 + elRect.height / 2; - viewport.scrollTop = Math.max(0, offset); - initialScrolledRef.current = true; - anchorScrollAttemptsRef.current = 0; - setInitialAnchorId(null); - setInitialAnchorIndex(null); + const cRect = viewport.getBoundingClientRect(); + const eRect = el.getBoundingClientRect(); + viewport.scrollTop = Math.max(0, eRect.top - cRect.top + viewport.scrollTop - viewport.clientHeight / 2 + eRect.height / 2); + scroll.syncScrollToBottomState(); + cm.markInitialScrollDone(); return; } - // Пробуем по индексу - if (typeof initialAnchorIndex === "number" && initialAnchorIndex >= 0) { - const children = viewport.querySelectorAll("[data-message-id]"); - const byIndex = children.item(initialAnchorIndex) as HTMLElement | null; + if (typeof cm.initialAnchorIndex === "number" && cm.initialAnchorIndex >= 0) { + const byIndex = viewport.querySelectorAll("[data-message-id]").item(cm.initialAnchorIndex) as HTMLElement | null; if (byIndex) { - const containerRect = viewport.getBoundingClientRect(); - const elRect = byIndex.getBoundingClientRect(); - const offset = elRect.top - containerRect.top + viewport.scrollTop - viewport.clientHeight / 2 + elRect.height / 2; - viewport.scrollTop = Math.max(0, offset); - initialScrolledRef.current = true; - anchorScrollAttemptsRef.current = 0; - setInitialAnchorId(null); - setInitialAnchorIndex(null); + const cRect = viewport.getBoundingClientRect(); + const eRect = byIndex.getBoundingClientRect(); + viewport.scrollTop = Math.max(0, eRect.top - cRect.top + viewport.scrollTop - viewport.clientHeight / 2 + eRect.height / 2); + scroll.syncScrollToBottomState(); + cm.markInitialScrollDone(); return; } } - // Даем DOM время на отрисовку (максимум 5 попыток, iOS рендерит медленнее) - if (anchorScrollAttemptsRef.current < 5) { - anchorScrollAttemptsRef.current += 1; - setTimeout(() => setAnchorRetryTick((v) => v + 1), 100); + // Retry up to 5 times for slow DOM rendering + if (cm.anchorRetryTick < 5) { + setTimeout(() => cm.setAnchorRetryTick(v => v + 1), 100); return; } - - // Якорь не найден после 5 попыток - скроллим вниз - console.warn('Anchor not found after 5 attempts, scrolling to bottom'); - setInitialAnchorId(null); - setInitialAnchorIndex(null); } - // Обычная прокрутка вниз без анимации (автоматический скролл при первой загрузке чата) - component.scrollToBottom('auto'); - initialScrolledRef.current = true; - anchorScrollAttemptsRef.current = 0; - anchorOlderLoadAttemptsRef.current = 0; - }, [ - messagesLoading, - messages, - initialAnchorId, - initialAnchorIndex, - anchorRetryTick, - ]); + component.scrollToBottom("auto"); + scroll.syncScrollToBottomState(); + cm.markInitialScrollDone(); + }, [cm, displayMessages, scroll]); - // Удалено: prependAdjustRef useLayoutEffect - // ScrollableMessageList уже корректирует позицию автоматически через getSnapshotBeforeUpdate - // Дублирующая корректировка вызывала "подергивание" при загрузке старых сообщений + /* ── scroll handler: pagination + mark-read ── */ + useEffect(() => { + const c = messagesViewportRef.current; + if (!c?.containerRef?.current) return; + const viewport = c.containerRef.current; - const loadOlderMessages = useCallback(async () => { - if (!chatId || Number.isNaN(chatId) || loadingOlder || messagesLoading || messages.length === 0) { - return; - } - - if (!hasMoreOlder && !allowOneOlderProbe) { - return; - } - - const oldestMessage = messages[0]; - - try { - setLoadingOlder(true); - const response = await apiClient.getChatMessages(chatId, { - limit: 40, - before_id: oldestMessage.id, - }); - - const olderMessages = response.messages || []; - if (olderMessages.length > 0) { - // ScrollableMessageList автоматически сохранит позицию через getSnapshotBeforeUpdate - setMessages((prev) => uniqueMessagesById([...olderMessages, ...prev])); + const onScroll = () => { + if (!cm.loadingOlder && !cm.messagesLoading && cm.hasMoreOlder && viewport.scrollTop <= 120) { + cm.loadOlderMessages(); } - setHasMoreOlder(Boolean(response.has_more)); - setAllowOneOlderProbe(false); - if (!response.has_more && (!response.messages || response.messages.length === 0)) { - anchorOlderLoadAttemptsRef.current = 3; - } - } catch (e) { - console.error("Ошибка подгрузки старых сообщений:", e); - } finally { - setLoadingOlder(false); - } - }, [allowOneOlderProbe, chatId, hasMoreOlder, loadingOlder, messages, messagesLoading]); - - const loadNewerMessages = useCallback(async () => { - if (!chatId || Number.isNaN(chatId) || loadingNewer || messagesLoading || !hasMoreNewer || messages.length === 0) { - return; - } - - const newestMessage = messages[messages.length - 1]; - const shouldMarkRead = isNearBottom(); - - try { - setLoadingNewer(true); - const response = await apiClient.getChatMessages(chatId, { - limit: 10, // Ограничено для минимальной погрешности автоотметки (было 40) - after_id: newestMessage.id, - mark_read: shouldMarkRead, - }); - - const newerMessages = response.messages || []; - if (newerMessages.length > 0) { - setMessages((prev) => uniqueMessagesById([...prev, ...newerMessages])); - - if (shouldMarkRead) { - const lastLoadedMessage = newerMessages[newerMessages.length - 1]; - setChat((prev) => - prev - ? { - ...prev, - last_read_message_id: Math.max(prev.last_read_message_id ?? 0, lastLoadedMessage.id), - } - : prev - ); - } - - // НЕ делаем автоскролл - ScrollableMessageList сам решит: - // - Если был внизу (sticky) → автоматически проскроллит вниз - // - Если был НЕ внизу → сохранит текущую позицию - // Это правильное поведение для дозагрузки истории + const distFromBottom = viewport.scrollHeight - viewport.scrollTop - viewport.clientHeight; + if (!cm.loadingNewer && !cm.messagesLoading && cm.hasMoreNewer && distFromBottom <= 300) { + cm.loadNewerMessages(); } - setHasMoreNewer(Boolean(response.has_more)); - } catch (e) { - console.error("Ошибка подгрузки новых сообщений:", e); - } finally { - setLoadingNewer(false); - } - }, [chatId, hasMoreNewer, isNearBottom, loadingNewer, messages, messagesLoading]); + const isAtBottom = scroll.syncScrollToBottomState(); + if (isAtBottom && !cm.hasMoreNewer && cm.messages.length > 0) { + const latest = cm.messages[cm.messages.length - 1]; + markRead.scheduleMarkRead(latest.id); + cm.resetNewMessagesBelow(); + } + }; + + viewport.addEventListener("scroll", onScroll, { passive: true }); + return () => viewport.removeEventListener("scroll", onScroll); + }, [cm, markRead, scroll]); + + /* ── fallback sync ── */ useChatFallbackSync({ chatId: chatId || null, isConnected, userLoading, hasUser: Boolean(user), - messagesLoading, - hasMessages: messages.length > 0, - isNearBottom, - syncLatestMessages, + messagesLoading: cm.messagesLoading, + hasMessages: cm.messages.length > 0, + isNearBottom: scroll.isNearBottom, + syncLatestMessages: cm.syncLatestMessages, }); - useEffect(() => { - const component = messagesViewportRef.current; - if (!component || !component.containerRef?.current) return; - - const viewport = component.containerRef.current; + /* ── composer helpers ── */ - const onScroll = () => { - // Загрузка старых сообщений при приближении к верху - if (!loadingOlder && !messagesLoading && hasMoreOlder && viewport.scrollTop <= 120) { - loadOlderMessages(); - } - - // Загрузка новых сообщений при приближении к низу - const distanceFromBottom = viewport.scrollHeight - viewport.scrollTop - viewport.clientHeight; - if (!loadingNewer && !messagesLoading && hasMoreNewer && distanceFromBottom <= 120) { - loadNewerMessages(); - } - - // Обновление плавающей даты - const messageEls = viewport.querySelectorAll('[data-message-date]'); - let topDate: string | null = null; - const viewportTop = viewport.getBoundingClientRect().top; - - for (let i = 0; i < messageEls.length; i++) { - const el = messageEls[i]; - const rect = el.getBoundingClientRect(); - if (rect.bottom > viewportTop) { - const isoDate = el.getAttribute('data-message-date'); - if (isoDate) { - const d = new Date(isoDate); - if (!Number.isNaN(d.getTime())) { - topDate = formatDayDivider(d); - } - } - break; - } - } - - if (topDate) { - setFloatingDate(topDate); - setShowFloatingDate(true); - - if (floatingDateTimeoutRef.current) clearTimeout(floatingDateTimeoutRef.current); - floatingDateTimeoutRef.current = setTimeout(() => { - setShowFloatingDate(false); - }, 1500); - } - - // Показ кнопки scroll-to-bottom когда не внизу - const isAtBottom = isNearBottom(); - setShowScrollToBottom(!isAtBottom); - }; - - viewport.addEventListener("scroll", onScroll, { passive: true }); - return () => viewport.removeEventListener("scroll", onScroll); - }, [chatId, hasMoreOlder, hasMoreNewer, isNearBottom, loadNewerMessages, loadOlderMessages, loadingOlder, loadingNewer, messagesLoading, messages]); - - const handleSend = async () => { - const text = messageText.trim(); - if (!chatId || sending) return; - if (editingMessageId) { - if (!text) return; - } else if (!text && attachedFiles.length === 0) { - return; - } - - // Запоминаем позицию ДО отправки - const wasNearBottom = isNearBottom(); - - try { - setSending(true); - if (editingMessageId) { - const updated = await apiClient.updateMessage(editingMessageId, text); - setMessages((prev) => prev.map((m) => (m.id === editingMessageId ? { ...m, ...updated } : m))); - } else { - const sent = attachedFiles.length - ? await apiClient.sendMessageWithFiles(chatId, text, attachedFiles, replyTo?.id) - : await apiClient.sendMessage(chatId, text, replyTo?.id); - setMessages((prev) => uniqueMessagesById([...prev, sent])); - } - - setEditingMessageId(null); - setMessageText(""); - setReplyTo(null); - setExpandedReplyActionForId(null); - setAttachedFiles([]); - if (fileInputRef.current) { - fileInputRef.current.value = ""; - } - - // Автоскролл вниз ТОЛЬКО если был внизу при отправке - // Если читал историю вверху - не скроллим, оставляем там где был - if (wasNearBottom) { - requestAnimationFrame(() => { - scrollToBottom(false); - }); - } - } catch (e) { - console.error("Ошибка отправки сообщения:", e); - } finally { - setSending(false); - } - }; - - const handlePickFiles = () => { - if (editingMessageId) return; - fileInputRef.current?.click(); - }; - - const handleFilesChange = (e: React.ChangeEvent) => { - const files = Array.from(e.target.files || []); - if (!files.length) return; - setAttachedFiles((prev) => [...prev, ...files]); - }; - - const removeAttachedFile = (index: number) => { - setAttachedFiles((prev) => prev.filter((_, i) => i !== index)); - }; + const resetComposerState = useCallback(() => { + setEditingMessageId(null); + setMessageText(""); + setReplyTo(null); + setExpandedReplyActionForId(null); + setAttachedFiles([]); + if (fileInputRef.current) fileInputRef.current.value = ""; + }, []); const focusMessageInput = () => { const input = messageInputRef.current; if (!input) return; input.focus(); - const len = input.value.length; - input.setSelectionRange(len, len); + input.setSelectionRange(input.value.length, input.value.length); }; - const handleReplyToMessage = (message: Message) => { - const author = message.author_name || message.author?.last_name || message.sender?.last_name || "Сотрудник"; - const preview = getMessagePreviewText(message); - setEditingMessageId(null); - setReplyTo({ id: message.id, author, preview }); - setExpandedReplyActionForId(null); - setActionsMenuAnchor(null); - requestAnimationFrame(focusMessageInput); + const appendEmojiToComposer = (emoji: string) => { + setMessageText(prev => `${prev}${emoji}`); + requestAnimationFrame(() => focusMessageInput()); }; + const handleSend = async () => { + const text = messageText.trim(); + let optimisticLocalId: string | null = null; + + if (!chatId || (editingMessageId && sending)) return; + if (editingMessageId) { if (!text) return; } + else if (!text && attachedFiles.length === 0) return; + + const wasNearBottom = scroll.isNearBottom(); + + try { + if (editingMessageId) { + setSending(true); + const updated = await apiClient.updateMessage(editingMessageId, text); + cm.setMessages(prev => prev.map(m => m.id === editingMessageId ? { ...m, ...updated } : m)); + resetComposerState(); + } else { + const files = [...attachedFiles]; + const replyToId = replyTo?.id; + const localId = typeof crypto !== "undefined" && typeof crypto.randomUUID === "function" + ? crypto.randomUUID() + : `local-${Date.now()}-${Math.random().toString(16).slice(2)}`; + optimisticLocalId = localId; + const opt = cm.buildOptimistic(text, files, localId, replyToId); + cm.addPending(opt); + cm.schedulePendingTimer(localId); + resetComposerState(); + + const sent = files.length + ? await apiClient.sendMessageWithFiles(chatId, text, files, replyToId) + : await apiClient.sendMessage(chatId, text, replyToId); + + cm.removePendingMessage(localId); + cm.setMessages(prev => uniqueMessagesById([...prev, sent])); + + if (wasNearBottom) requestAnimationFrame(() => scroll.scrollToBottom(false)); + return; + } + + if (wasNearBottom) requestAnimationFrame(() => scroll.scrollToBottom(false)); + } catch (e) { + console.error("Ошибка отправки сообщения:", e); + if (!editingMessageId && optimisticLocalId) cm.markPendingFailed(optimisticLocalId); + } finally { + if (editingMessageId) setSending(false); + } + }; + + /* ── file handling ── */ + + const addAttachedFiles = useCallback((files: File[]) => { + if (editingMessageId || files.length === 0) return; + setAttachedFiles(prev => [...prev, ...files]); + }, [editingMessageId]); + + const handleFilesChange = (e: React.ChangeEvent) => addAttachedFiles(Array.from(e.target.files || [])); + const removeAttachedFile = (index: number) => setAttachedFiles(prev => prev.filter((_, i) => i !== index)); + const handlePickFiles = () => { if (!editingMessageId) fileInputRef.current?.click(); }; + + /* ── message actions ── */ + const canManageMessage = (message: Message): boolean => { if (!user?.id || message.is_deleted) return false; return message.author_id === user.id || message.author?.id === user.id || message.sender?.id === user.id; }; + const handleReplyToMessage = (message: Message) => { + const author = message.author_name || message.author?.last_name || message.sender?.last_name || "Сотрудник"; + setEditingMessageId(null); + setReplyTo({ id: message.id, author, preview: getMessagePreviewText(message) }); + setExpandedReplyActionForId(null); + setIsReadersModalOpen(false); + setActionsMenuAnchor(null); + requestAnimationFrame(focusMessageInput); + }; + const handleStartEditMessage = (message: Message) => { if (!canManageMessage(message)) return; - setEditingMessageId(message.id); setReplyTo(null); setExpandedReplyActionForId(null); setMessageText(message.content || ""); setAttachedFiles([]); - if (fileInputRef.current) { - fileInputRef.current.value = ""; - } - + if (fileInputRef.current) fileInputRef.current.value = ""; requestAnimationFrame(focusMessageInput); }; - const handleCancelEdit = () => { - setEditingMessageId(null); - setMessageText(""); - }; + const handleCancelEdit = () => { setEditingMessageId(null); setMessageText(""); }; const handleDeleteMessage = async (message: Message) => { if (!canManageMessage(message)) return; - try { await apiClient.deleteMessage(message.id); - - setMessages((prev) => - prev.map((m) => - m.id === message.id - ? { - ...m, - content: "", - attachments: [], - has_attachments: false, - is_deleted: true, - } - : m - ) - ); - - if (replyTo?.id === message.id) { - setReplyTo(null); - } - if (editingMessageId === message.id) { - setEditingMessageId(null); - setMessageText(""); - } - + cm.setMessages(prev => prev.map(m => m.id === message.id ? { ...m, content: "", attachments: [], has_attachments: false, is_deleted: true } : m)); + if (replyTo?.id === message.id) setReplyTo(null); + if (editingMessageId === message.id) { setEditingMessageId(null); setMessageText(""); } setExpandedReplyActionForId(null); + setIsReadersModalOpen(false); setActionsMenuAnchor(null); } catch (e) { console.error("Ошибка удаления сообщения:", e); @@ -968,12 +562,9 @@ export default function MessageDialogPage() { }; const handleToggleMessageActions = useCallback((messageId: number, anchor: MessageActionsAnchor) => { - setExpandedReplyActionForId((prev) => { - if (prev === messageId) { - setActionsMenuAnchor(null); - return null; - } - + setExpandedReplyActionForId(prev => { + if (prev === messageId) { setIsReadersModalOpen(false); setActionsMenuAnchor(null); return null; } + setIsReadersModalOpen(false); setReactionPickerForMessageId(null); setShowComposerEmojiPicker(false); setActionsMenuAnchor(anchor); @@ -981,74 +572,291 @@ export default function MessageDialogPage() { }); }, []); - const handleAttachmentLoad = useCallback((attachmentId: number) => { - setBrokenMedia((prev) => ({ ...prev, [attachmentId]: false })); + const handleAttachmentLoad = useCallback((id: number) => { + setBrokenMedia(prev => ({ ...prev, [id]: false })); messagesViewportRef.current?.updateScrollPosition(); }, []); + const handleAttachmentError = useCallback((id: number) => { setBrokenMedia(prev => ({ ...prev, [id]: true })); }, []); + const handleUseOriginalImage = useCallback((id: number) => { setUseOriginalImage(prev => ({ ...prev, [id]: true })); }, []); - const handleAttachmentError = useCallback((attachmentId: number) => { - setBrokenMedia((prev) => ({ ...prev, [attachmentId]: true })); - }, []); + /* ── date navigation ── */ - const handleUseOriginalImage = useCallback((attachmentId: number) => { - setUseOriginalImage((prev) => ({ ...prev, [attachmentId]: true })); - }, []); + const handleJumpToDate = useCallback(async (dateValue: string) => { + if (jumpingToDate) return; + setJumpingToDate(true); + scroll.setIsDateNavigatorOpen(false); + await cm.jumpToDate(dateValue); + setJumpingToDate(false); + }, [cm, jumpingToDate, scroll]); + + const handleReturnToNewMessages = useCallback(async () => { + const c = messagesViewportRef.current?.containerRef?.current; + + if (!cm.hasMoreNewer && !cm.historyNavigationMode) { + cm.setHistoryNavigationMode(false); + if (c) c.scrollTop = c.scrollHeight; + cm.resetNewMessagesBelow(); + return; + } + + const result = await cm.returnToUnread(); + if (!result) { + if (c) c.scrollTop = c.scrollHeight; + cm.setHistoryNavigationMode(false); + cm.resetNewMessagesBelow(); + } else { + scroll.setShowScrollToBottom(true); + } + }, [cm, scroll]); + + /* ── chat settings toggles ── */ const handleTogglePin = async () => { if (!chatId) return; - try { - const response = await apiClient.togglePinChat(chatId); - const newIsPinned = response.is_pinned ?? !isPinned; - setIsPinned(newIsPinned); - - // Обновляем chat объект - if (chat) { - setChat({ ...chat, is_pinned: newIsPinned }); - } - } catch (e) { - console.error("Ошибка переключения закрепления:", e); - } + const resp = await apiClient.togglePinChat(chatId); + const v = resp.is_pinned ?? !isPinned; + setIsPinned(v); + if (cm.chat) cm.setChat({ ...cm.chat, is_pinned: v }); + } catch (e) { console.error("Ошибка переключения закрепления:", e); } }; const handleToggleNotifications = async () => { if (!chatId) return; - try { - const response = await apiClient.toggleChatNotifications(chatId); - const newNotificationsEnabled = response.notifications_enabled ?? !notificationsEnabled; - setNotificationsEnabled(newNotificationsEnabled); - - // Обновляем chat объект - if (chat) { - setChat({ ...chat, notifications_enabled: newNotificationsEnabled }); - } - } catch (e) { - console.error("Ошибка переключения уведомлений:", e); - } + const resp = await apiClient.toggleChatNotifications(chatId); + const v = resp.notifications_enabled ?? !notificationsEnabled; + setNotificationsEnabled(v); + if (cm.chat) cm.setChat({ ...cm.chat, notifications_enabled: v }); + } catch (e) { console.error("Ошибка переключения уведомлений:", e); } }; - useEffect(() => { - const onWindowClick = (e: MouseEvent) => { - const target = e.target as HTMLElement | null; - if (!target) return; - if (target.closest("[data-actions-menu='true']")) return; - if (target.closest("[data-actions-trigger='true']")) return; - if (target.closest("[data-reaction-picker='true']")) return; - if (target.closest("[data-composer-emoji='true']")) return; + const openSearchPanel = useCallback(() => { + setIsSearchOpen(true); + setExpandedReplyActionForId(null); + setActionsMenuAnchor(null); + setReactionPickerForMessageId(null); + setShowComposerEmojiPicker(false); + scroll.setIsDateNavigatorOpen(false); + }, [scroll]); + const closeSearchPanel = useCallback(() => { + setIsSearchOpen(false); + setSearchQuery(""); + setSearchResults([]); + setSearchTotalCount(0); + setSelectedSearchResultIndex(0); + setSearchLoading(false); + setSearchLoadingMore(false); + setSearchError(null); + setNextSearchOffset(null); + }, []); + + const highlightFoundMessage = useCallback((messageId: number) => { + if (highlightTimerRef.current) clearTimeout(highlightTimerRef.current); + setHighlightedMessageId(messageId); + highlightTimerRef.current = setTimeout(() => { + setHighlightedMessageId(null); + highlightTimerRef.current = null; + }, 2200); + }, []); + + const clearMessageParam = useCallback(() => { + if (!searchParams.get("message")) return; + const nextParams = new URLSearchParams(searchParams.toString()); + nextParams.delete("message"); + router.replace(nextParams.toString() ? `${pathname}?${nextParams.toString()}` : pathname, { scroll: false }); + }, [pathname, router, searchParams]); + + useEffect(() => { + if (!linkedMessageId) { + return; + } + + let cancelled = false; + + void (async () => { + const jumped = await cm.jumpToMessage(linkedMessageId); + if (cancelled) { + return; + } + if (jumped) { + highlightFoundMessage(linkedMessageId); + } + clearMessageParam(); + })(); + + return () => { + cancelled = true; + }; + }, [clearMessageParam, cm.jumpToMessage, highlightFoundMessage, linkedMessageId]); + + const runSearch = useCallback(async (query: string, options?: { append?: boolean; offset?: number }) => { + const normalizedQuery = query.trim(); + if (!chatId || normalizedQuery.length < 2) { + setSearchResults([]); + setSearchTotalCount(0); + setSelectedSearchResultIndex(0); + setSearchError(null); + setNextSearchOffset(null); + return; + } + + const append = Boolean(options?.append); + if (append) { + setSearchLoadingMore(true); + } else { + setSearchLoading(true); + } + setSearchError(null); + + try { + const response: ChatMessageSearchResponse = await apiClient.searchChatMessages(chatId, { + q: normalizedQuery, + limit: 20, + offset: options?.offset ?? 0, + }); + + setSearchTotalCount(response.count); + setNextSearchOffset(response.next_offset); + setSearchResults((prev) => { + if (!append) { + return response.results; + } + + const seen = new Set(prev.map((item) => item.message_id)); + const merged = [...prev]; + response.results.forEach((item: ChatMessageSearchResult) => { + if (!seen.has(item.message_id)) { + seen.add(item.message_id); + merged.push(item); + } + }); + return merged; + }); + + if (!append) { + setSelectedSearchResultIndex(0); + } + } catch (e) { + console.error("Ошибка поиска по сообщениям:", e); + setSearchError("Не удалось выполнить поиск по сообщениям."); + } finally { + if (append) { + setSearchLoadingMore(false); + } else { + setSearchLoading(false); + } + } + }, [chatId]); + + useEffect(() => { + if (!isSearchOpen) { + return; + } + + const normalizedQuery = searchQuery.trim(); + if (normalizedQuery.length < 2) { + setSearchResults([]); + setSearchTotalCount(0); + setSelectedSearchResultIndex(0); + setSearchError(null); + setNextSearchOffset(null); + setSearchLoading(false); + setSearchLoadingMore(false); + return; + } + + const timer = setTimeout(() => { + void runSearch(normalizedQuery, { append: false, offset: 0 }); + }, 250); + + return () => clearTimeout(timer); + }, [isSearchOpen, runSearch, searchQuery]); + + const handleSelectSearchResult = useCallback(async (index: number) => { + const result = searchResults[index]; + if (!result) { + return; + } + + setSelectedSearchResultIndex(index); + const jumped = await cm.jumpToMessage(result.message_id); + if (jumped) { + highlightFoundMessage(result.message_id); + setExpandedReplyActionForId(null); + setActionsMenuAnchor(null); + setReactionPickerForMessageId(null); + setIsReadersModalOpen(false); + } + }, [cm, highlightFoundMessage, searchResults]); + + const handleJumpToReply = useCallback(async (messageId: number) => { + if (!messageId) { + return; + } + + const jumped = await cm.jumpToMessage(messageId); + if (jumped) { + highlightFoundMessage(messageId); + setExpandedReplyActionForId(null); + setActionsMenuAnchor(null); + setReactionPickerForMessageId(null); + setIsReadersModalOpen(false); + } + }, [cm, highlightFoundMessage]); + + const handleSubmitSelectedSearchResult = useCallback(() => { + if (searchResults.length === 0) { + return; + } + + void handleSelectSearchResult(selectedSearchResultIndex); + }, [handleSelectSearchResult, searchResults.length, selectedSearchResultIndex]); + + const handlePreviousSearchResult = useCallback(() => { + if (searchResults.length === 0) { + return; + } + + const nextIndex = selectedSearchResultIndex <= 0 ? searchResults.length - 1 : selectedSearchResultIndex - 1; + void handleSelectSearchResult(nextIndex); + }, [handleSelectSearchResult, searchResults.length, selectedSearchResultIndex]); + + const handleNextSearchResult = useCallback(() => { + if (searchResults.length === 0) { + return; + } + + const nextIndex = selectedSearchResultIndex >= searchResults.length - 1 ? 0 : selectedSearchResultIndex + 1; + void handleSelectSearchResult(nextIndex); + }, [handleSelectSearchResult, searchResults.length, selectedSearchResultIndex]); + + const handleLoadMoreSearchResults = useCallback(() => { + const normalizedQuery = searchQuery.trim(); + if (!normalizedQuery || nextSearchOffset === null || searchLoadingMore) { + return; + } + + void runSearch(normalizedQuery, { append: true, offset: nextSearchOffset }); + }, [nextSearchOffset, runSearch, searchLoadingMore, searchQuery]); + + /* ── global click / escape dismissals ── */ + + useEffect(() => { + const onClick = (e: MouseEvent) => { + const t = e.target as HTMLElement | null; + if (!t) return; + if (t.closest("[data-actions-menu='true'],[data-actions-trigger='true'],[data-reaction-picker='true'],.reaction-picker-modal,[data-composer-emoji='true'],[data-date-navigator='true'],[data-date-trigger='true']")) return; setExpandedReplyActionForId(null); setActionsMenuAnchor(null); setReactionPickerForMessageId(null); setShowComposerEmojiPicker(false); + scroll.setIsDateNavigatorOpen(false); }; - - window.addEventListener("mousedown", onWindowClick); - return () => window.removeEventListener("mousedown", onWindowClick); - }, []); - - const isInteractiveArea = (target: HTMLElement): boolean => - Boolean(target.closest("textarea, input, button, a, video, audio, [contenteditable='true']")); + window.addEventListener("mousedown", onClick); + return () => window.removeEventListener("mousedown", onClick); + }, [scroll]); useEffect(() => { const onKey = (e: KeyboardEvent) => { @@ -1058,165 +866,218 @@ export default function MessageDialogPage() { setActionsMenuAnchor(null); setReactionPickerForMessageId(null); setShowComposerEmojiPicker(false); + scroll.setIsDateNavigatorOpen(false); + if (isSearchOpen) closeSearchPanel(); } }; window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); - }, []); + }, [closeSearchPanel, isSearchOpen, scroll]); - // При открытии мобильной клавиатуры прокручиваем чат вниз + // Mobile keyboard viewport adjustment useEffect(() => { const vv = window.visualViewport; if (!vv) return; - - let prevHeight = vv.height; - + let prevH = vv.height; const onResize = () => { - const newHeight = vv.height; - if (newHeight < prevHeight) { - requestAnimationFrame(() => { - messagesViewportRef.current?.scrollToBottom('auto'); - }); - } - prevHeight = newHeight; + if (vv.height < prevH) requestAnimationFrame(() => messagesViewportRef.current?.scrollToBottom("auto")); + prevH = vv.height; }; - - vv.addEventListener('resize', onResize); - return () => vv.removeEventListener('resize', onResize); + vv.addEventListener("resize", onResize); + return () => vv.removeEventListener("resize", onResize); }, []); + // Redirect wheel events outside chat container into it useEffect(() => { - const onGlobalWheel = (e: WheelEvent) => { + const onWheel = (e: WheelEvent) => { if (e.defaultPrevented || mediaPreview) return; - - const component = messagesViewportRef.current; - if (!component || !component.containerRef?.current) return; - const viewport = component.containerRef.current; - - const target = e.target as HTMLElement | null; - if (!target) return; - - if (viewport.contains(target)) return; - if (isInteractiveArea(target)) return; - + const c = messagesViewportRef.current; + if (!c?.containerRef?.current) return; + const v = c.containerRef.current; + const t = e.target as HTMLElement | null; + if ( + !t || + v.contains(t) || + t.closest("[data-overlay-root='true'], [role='dialog'], [aria-modal='true']") || + t.closest("textarea,input,button,a,video,audio,[contenteditable='true']") + ) { + return; + } e.preventDefault(); - viewport.scrollTop += e.deltaY; + v.scrollTop += e.deltaY; }; - - window.addEventListener("wheel", onGlobalWheel, { passive: false }); - return () => window.removeEventListener("wheel", onGlobalWheel); + window.addEventListener("wheel", onWheel, { passive: false }); + return () => window.removeEventListener("wheel", onWheel); }, [mediaPreview]); + /* ────────────────────────────────────────────────────── + * RENDER + * ────────────────────────────────────────────────────── */ + return ( - {loading ? ( + {cm.loading ? (

Загрузка чатов...

- ) : error ? ( + ) : cm.error ? (
-

{error}

+

{cm.error}

) : (
-
- {chat ? ( +
+ {cm.chat ? ( <> + + { + void handleSelectSearchResult(index); + }} + onLoadMore={handleLoadMoreSearchResults} + onSubmitSelection={handleSubmitSelectedSearchResult} />
- {/* Плавающая дата */} - {floatingDate && ( -
- - {floatingDate} - + {/* Floating date */} + {scroll.floatingDate ? ( +
+
- )} + ) : null} + {/* Date navigator panel */} + {scroll.isDateNavigatorOpen ? ( +
+

Перейти к дате

+
+ + scroll.setSelectedHistoryDate(e.target.value)} className="h-9 flex-1 rounded-lg border border-gray-200 bg-white px-3 text-sm text-gray-800 outline-none transition focus:border-sky-400 focus:ring-2 focus:ring-sky-100" /> + +
+
+ + + +
+
+ ) : null} + + {/* Message list */} - {messagesLoading ? ( + {cm.messagesLoading ? (

Загрузка сообщений...

- ) : messages.length === 0 ? ( + ) : displayMessages.length === 0 ? (

Пока нет сообщений. Напишите первым.

) : (
- {loadingOlder ? ( -

Подгружаем старые сообщения...

- ) : null} - {messages.map((message) => { + {cm.loadingOlder ?

Подгружаем старые сообщения...

: null} + {displayMessages.map((message, index) => { const replyToId = getReplyToId(message); const repliedMessage = replyToId ? messagesById.get(replyToId) : null; + const curDay = getMessageDayKey(message); + const prevDay = index > 0 ? getMessageDayKey(displayMessages[index - 1]) : null; + const curDate = getMessageDate(message); + const showDayDivider = Boolean(curDate && curDay !== prevDay); return ( - + + {showDayDivider && curDate ? ( +
+ +
+ ) : null} + { + void handleJumpToReply(messageId); + }} + onOpenMediaPreview={setMediaPreview} + onAttachmentLoad={handleAttachmentLoad} + onAttachmentError={handleAttachmentError} + onUseOriginalImage={handleUseOriginalImage} + onReact={handleReact} + hasMyReaction={hasMyReaction} + /> +
); })}
)}
- {/* Кнопка прокрутки вниз */} - {showScrollToBottom && ( + {/* Scroll-to-bottom button */} + {scroll.showScrollToBottom && (
)} -
+ {/* Composer area */} +
{isTyping && (
- - - + + +
Собеседник печатает...
@@ -1229,24 +1090,22 @@ export default function MessageDialogPage() { replyTo={replyTo} attachedFiles={attachedFiles} messageText={messageText} - sending={sending} + sending={sendingComposer} showEmojiPicker={showComposerEmojiPicker} allReactions={ALL_REACTIONS} fileInputRef={fileInputRef} messageInputRef={messageInputRef} onPickFiles={handlePickFiles} + onAddFiles={addAttachedFiles} onFilesChange={handleFilesChange} onRemoveFile={removeAttachedFile} onToggleEmojiPicker={() => { - setShowComposerEmojiPicker((prev) => !prev); + setShowComposerEmojiPicker(prev => !prev); setExpandedReplyActionForId(null); setActionsMenuAnchor(null); setReactionPickerForMessageId(null); }} - onSelectEmoji={(emoji) => { - appendEmojiToComposer(emoji); - setShowComposerEmojiPicker(false); - }} + onSelectEmoji={(emoji) => { appendEmojiToComposer(emoji); setShowComposerEmojiPicker(false); }} onInputClick={() => setShowComposerEmojiPicker(false)} onChangeMessage={setMessageText} onTyping={sendTyping} @@ -1274,31 +1133,30 @@ export default function MessageDialogPage() { {expandedReplyActionForId && actionsMenuAnchor && selectedActionMessage ? ( { - handleReact(selectedActionMessage, emoji); - setExpandedReplyActionForId(null); - setActionsMenuAnchor(null); - }} + onQuickReact={(emoji) => { handleReact(selectedActionMessage, emoji); setExpandedReplyActionForId(null); setIsReadersModalOpen(false); setActionsMenuAnchor(null); }} onOpenReactionPicker={() => setReactionPickerForMessageId(selectedActionMessage.id)} + onShowAllReaders={() => setIsReadersModalOpen(true)} onReply={() => handleReplyToMessage(selectedActionMessage)} onEdit={() => handleStartEditMessage(selectedActionMessage)} onDelete={() => handleDeleteMessage(selectedActionMessage)} /> ) : null} + {selectedActionMessage ? ( + setIsReadersModalOpen(false)} readers={selectedActionReaders} /> + ) : null} + {reactionPickerForMessageId ? ( setReactionPickerForMessageId(null)} onSelect={(emoji) => { - const message = messagesById.get(reactionPickerForMessageId); - if (message) { - void handleReact(message, emoji); - } + const msg = messagesById.get(reactionPickerForMessageId); + if (msg) void handleReact(msg, emoji); setReactionPickerForMessageId(null); setExpandedReplyActionForId(null); setActionsMenuAnchor(null); diff --git a/frontend/src/app/messages/page.tsx b/frontend/src/app/messages/page.tsx index f10e7665..8f693016 100644 --- a/frontend/src/app/messages/page.tsx +++ b/frontend/src/app/messages/page.tsx @@ -11,6 +11,7 @@ import Link from "next/link"; import Image from "next/image"; import { useUser } from "@/contexts/UserContext"; import { useRouter } from "next/navigation"; +import { Modal } from "@/components/ui"; type UnreadFilter = "all" | "unread" | "read"; type PinnedFilter = "all" | "pinned" | "unpinned"; @@ -112,8 +113,7 @@ export default function MessagesPage() { try { setLoading(true); setError(null); - const response = await apiClient.getChats(); - const items = response.results || []; + const items = await apiClient.getAllChats(); // FRONTEND-ONLY: добираем детали чатов, чтобы получить имя/аватар собеседника // для private/direct в случае, когда list не отдает avatar/participant_details. @@ -662,19 +662,25 @@ export default function MessagesPage() { )} {/* Модальное окно создания чата */} - {showCreateModal && ( -
-
-
-

Создать чат

- + setShowCreateModal(false)} title="Создать чат" size="sm" closeOnEsc={!creating} footer={ +
+ +
- + }>
{/* Тип чата */}
@@ -800,29 +806,8 @@ export default function MessagesPage() { )}
- - {/* Кнопки */} -
- - -
-
-
- )} + ); } diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index 6c6b8b16..0d7a9071 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -2,9 +2,11 @@ import { Heart, ImageIcon, MessageSquare, Paperclip, Pencil, Plus, Send, Trash2, X } from "lucide-react"; import { AppShell } from "../components/AppShell"; +import { Modal } from "@/components/ui"; import { apiClient } from "@/lib/api"; import { resolveMediaUrl } from "@/lib/url"; import { useEffect, useRef, useState } from "react"; +import { usePathname, useRouter, useSearchParams } from "next/navigation"; import type { Comment, Post } from "@/types/api"; import { useUser } from "@/contexts/UserContext"; @@ -18,6 +20,9 @@ type LikeUser = { export default function Home() { const { user } = useUser(); + const router = useRouter(); + const pathname = usePathname(); + const searchParams = useSearchParams(); const [posts, setPosts] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); @@ -76,6 +81,19 @@ export default function Home() { const auth = user?.auth; const authPerms = auth?.permissions || []; const authByApp = auth?.permissions_by_app || {}; + const linkedPostId = Number(searchParams.get("post") || ""); + + const clearPostParam = () => { + if (!searchParams.get("post")) return; + const nextParams = new URLSearchParams(searchParams.toString()); + nextParams.delete("post"); + router.replace(nextParams.toString() ? `${pathname}?${nextParams.toString()}` : pathname, { scroll: false }); + }; + + const closeCommentsModal = () => { + setCommentsOpen(false); + clearPostParam(); + }; const hasPermission = (perm: string) => { if (!perm) return false; @@ -170,7 +188,7 @@ export default function Home() { if (!commentsOpen && !createPostOpen) return; const onKeyDown = (e: KeyboardEvent) => { if (e.key === "Escape") { - setCommentsOpen(false); + closeCommentsModal(); } }; @@ -295,6 +313,16 @@ export default function Home() { await loadComments(post.id); }; + useEffect(() => { + if (!linkedPostId || posts.length === 0) return; + if (commentsOpen && activePost?.id === linkedPostId) return; + + const targetPost = posts.find((post) => post.id === linkedPostId); + if (!targetPost) return; + + void openComments(targetPost); + }, [activePost?.id, commentsOpen, linkedPostId, posts]); + const handleCreateComment = async () => { if (!activePost) return; const text = newComment.trim(); @@ -549,7 +577,7 @@ export default function Home() { : `${Math.floor(diffHours / 24)} дн. назад`; return ( -
+
@@ -683,152 +711,180 @@ export default function Home() { )}
- {createPostOpen ? ( -
- + +
+ } + > +
+ {createError ? ( +
{createError}
+ ) : null} -
-
-

- {editingPostId ? "Редактировать публикацию" : "Создать публикацию"} -

-