From d1aea28be92d2744516170a4d83bc50a9875fe40 Mon Sep 17 00:00:00 2001 From: lizerk Date: Fri, 3 Apr 2026 09:55:10 +0300 Subject: [PATCH 01/19] Add websocket read receipts and AsyncAPI docs --- backend/api/apps.py | 2 - backend/api/docs_views.py | 61 ++ backend/api/urls.py | 3 + backend/communications/api/viewsets.py | 31 +- backend/communications/consumers.py | 29 +- backend/communications/serialization.py | 26 + .../docs/architecture/websocket-asyncapi.yaml | 560 ++++++++++++++++++ backend/templates/api/websocket_docs.html | 137 +++++ backend/tests/api/test_documentation.py | 24 + frontend/src/app/messages/[chatId]/page.tsx | 33 +- .../components/messages/ChatMessageItem.tsx | 22 +- frontend/src/hooks/useWebSocket.ts | 1 + 12 files changed, 901 insertions(+), 28 deletions(-) create mode 100644 backend/api/docs_views.py create mode 100644 backend/docs/architecture/websocket-asyncapi.yaml create mode 100644 backend/templates/api/websocket_docs.html 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/communications/api/viewsets.py b/backend/communications/api/viewsets.py index b7beb286..0e400a6a 100644 --- a/backend/communications/api/viewsets.py +++ b/backend/communications/api/viewsets.py @@ -562,14 +562,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 +620,7 @@ 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', 'chat__read_states' ) # Определяем порядок сортировки в зависимости от типа запроса @@ -706,7 +719,7 @@ 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', 'chat__read_states' ) # Если нет around_id, пытаемся получить last_read_message_id @@ -1058,9 +1071,9 @@ 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', 'chat__read_states' ).get(pk=message.id) # Отправляем через WebSocket @@ -1215,9 +1228,9 @@ 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', 'chat__read_states' ).get(pk=instance.id) # WebSocket уведомление diff --git a/backend/communications/consumers.py b/backend/communications/consumers.py index 7b2a1ba2..fb6e82e1 100644 --- a/backend/communications/consumers.py +++ b/backend/communications/consumers.py @@ -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,8 @@ 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", "chat__read_states") .order_by("-created_at")[:limit] ) diff --git a/backend/communications/serialization.py b/backend/communications/serialization.py index f1f2467f..a3e216db 100644 --- a/backend/communications/serialization.py +++ b/backend/communications/serialization.py @@ -48,6 +48,31 @@ 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 serialize_message(m) -> dict: """ Сериализация сообщения с поддержкой всех полей. @@ -83,6 +108,7 @@ 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), "is_deleted": m.is_deleted, "is_pinned": m.is_pinned, "is_forwarded": m.is_forwarded, 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/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/frontend/src/app/messages/[chatId]/page.tsx b/frontend/src/app/messages/[chatId]/page.tsx index fa4a90c9..2c27c271 100644 --- a/frontend/src/app/messages/[chatId]/page.tsx +++ b/frontend/src/app/messages/[chatId]/page.tsx @@ -380,15 +380,34 @@ export default function MessageDialogPage() { } else if (data.type === 'marked_read' && data.chat_id === chatId && typeof data.last_read_message_id === 'number') { const lastReadMessageId = data.last_read_message_id; + const readerUserId = typeof data.reader_user_id === 'number' ? data.reader_user_id : null; - setChat((prev) => - prev - ? { - ...prev, - last_read_message_id: Math.max(prev.last_read_message_id ?? 0, lastReadMessageId), + if (readerUserId !== null) { + setMessages((prev) => + prev.map((message) => { + const authorId = message.author_id ?? message.author?.id ?? message.sender?.id; + if (message.id > lastReadMessageId || authorId === readerUserId || message.is_read) { + return message; } - : prev - ); + + return { + ...message, + is_read: true, + }; + }) + ); + } + + if (readerUserId === null || readerUserId === user?.id) { + setChat((prev) => + prev + ? { + ...prev, + last_read_message_id: Math.max(prev.last_read_message_id ?? 0, lastReadMessageId), + } + : prev + ); + } } }; diff --git a/frontend/src/components/messages/ChatMessageItem.tsx b/frontend/src/components/messages/ChatMessageItem.tsx index 28312c44..fec48055 100644 --- a/frontend/src/components/messages/ChatMessageItem.tsx +++ b/frontend/src/components/messages/ChatMessageItem.tsx @@ -1,7 +1,7 @@ "use client"; import Image from "next/image"; -import { ChevronRight, FileText } from "lucide-react"; +import { Check, CheckCheck, ChevronRight, FileText } from "lucide-react"; import { resolveMediaUrl } from "@/lib/url"; import { @@ -44,6 +44,20 @@ function getMessageAuthorLabel(message: Message): string { return message.author_name || message.author?.last_name || message.sender?.last_name || "Сотрудник"; } +function MessageDeliveryStatus({ isRead }: { isRead: boolean }) { + const Icon = isRead ? CheckCheck : Check; + + return ( + + + + ); +} + export default function ChatMessageItem({ message, currentUserId, @@ -68,6 +82,7 @@ export default function ChatMessageItem({ (message.author_id === currentUserId || message.author?.id === currentUserId || message.sender?.id === currentUserId) ); const hasActions = canReply || canManage; + const isRead = Boolean(message.is_read); return (
@@ -267,7 +282,10 @@ export default function ChatMessageItem({
) : null} -

{messageTime(message)}

+
+

{messageTime(message)}

+ {isMine ? : null} +
{Object.keys(message.reactions_summary || {}).length > 0 ? (
diff --git a/frontend/src/hooks/useWebSocket.ts b/frontend/src/hooks/useWebSocket.ts index 9ea596cf..d6b33ad2 100644 --- a/frontend/src/hooks/useWebSocket.ts +++ b/frontend/src/hooks/useWebSocket.ts @@ -12,6 +12,7 @@ interface WebSocketMessage { user_id?: number; chat_id?: number; last_read_message_id?: number; + reader_user_id?: number; reactions_summary?: ReactionsSummary; [key: string]: unknown; } From c5f53cca15ccd1a7c78dc50919abf0e1c67a80b0 Mon Sep 17 00:00:00 2001 From: lizerk Date: Fri, 3 Apr 2026 11:10:14 +0300 Subject: [PATCH 02/19] Improve chat delivery states and read details --- backend/communications/api/viewsets.py | 35 +- backend/communications/consumers.py | 13 +- backend/communications/serialization.py | 41 ++ .../communications/test_communications_api.py | 26 +- frontend/src/app/messages/[chatId]/page.tsx | 419 +++++++++++++++--- .../components/messages/ChatMessageItem.tsx | 44 +- .../messages/MessageActionsMenu.tsx | 105 ++++- .../messages/MessageReadersModal.tsx | 51 +++ frontend/src/hooks/useWebSocket.ts | 22 +- frontend/src/lib/messages/chatUtils.ts | 34 ++ frontend/src/types/api.ts | 11 + 11 files changed, 711 insertions(+), 90 deletions(-) create mode 100644 frontend/src/components/messages/MessageReadersModal.tsx diff --git a/backend/communications/api/viewsets.py b/backend/communications/api/viewsets.py index 0e400a6a..736a82d2 100644 --- a/backend/communications/api/viewsets.py +++ b/backend/communications/api/viewsets.py @@ -620,7 +620,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', 'chat__read_states' + 'attachments', + 'reactions', + 'reactions__user', + 'poll__options', + Prefetch( + 'chat__read_states', + queryset=ChatReadState.objects.select_related('user'), + ), ) # Определяем порядок сортировки в зависимости от типа запроса @@ -719,7 +726,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', 'chat__read_states' + 'attachments', + 'reactions', + 'reactions__user', + 'poll__options', + Prefetch( + 'chat__read_states', + queryset=ChatReadState.objects.select_related('user'), + ), ) # Если нет around_id, пытаемся получить last_read_message_id @@ -1073,7 +1087,13 @@ class MessageViewSet(viewsets.ModelViewSet): message = Message.objects.select_related( 'author', 'reply_to', 'reply_to__author', 'poll', 'chat' ).prefetch_related( - 'attachments', 'reactions', 'reactions__user', 'chat__read_states' + 'attachments', + 'reactions', + 'reactions__user', + Prefetch( + 'chat__read_states', + queryset=ChatReadState.objects.select_related('user'), + ), ).get(pk=message.id) # Отправляем через WebSocket @@ -1230,7 +1250,14 @@ class MessageViewSet(viewsets.ModelViewSet): instance = Message.objects.select_related( 'author', 'reply_to', 'reply_to__author', 'poll', 'chat' ).prefetch_related( - 'attachments', 'reactions', 'reactions__user', 'poll__options', 'chat__read_states' + '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 fb6e82e1..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__) @@ -724,7 +724,14 @@ class ChatConsumerMixin: messages = ( Message.objects.filter(chat_id=chat_id) .select_related("author", "chat") - .prefetch_related("attachments", "reactions", "chat__read_states") + .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 a3e216db..1464b7f9 100644 --- a/backend/communications/serialization.py +++ b/backend/communications/serialization.py @@ -73,6 +73,45 @@ def _is_message_read(m) -> bool: ).exists() +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, + } + ) + + readers.sort(key=lambda item: item["name"].lower()) + return readers + + def serialize_message(m) -> dict: """ Сериализация сообщения с поддержкой всех полей. @@ -109,12 +148,14 @@ 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: diff --git a/backend/tests/api/v1/communications/test_communications_api.py b/backend/tests/api/v1/communications/test_communications_api.py index 5d301502..ae83337c 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,30 @@ 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 сообщения содержит список пользователей, которые его прочитали.""" + 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()}] + def test_mark_read(self, auth_client, private_chat, user1): """Пометка чата как прочитанного""" url = f"/api/v1/communications/chats/{private_chat.pk}/mark-read/" diff --git a/frontend/src/app/messages/[chatId]/page.tsx b/frontend/src/app/messages/[chatId]/page.tsx index 2c27c271..e1c98dab 100644 --- a/frontend/src/app/messages/[chatId]/page.tsx +++ b/frontend/src/app/messages/[chatId]/page.tsx @@ -8,6 +8,7 @@ import ChatDialogHeader from "@/components/messages/ChatDialogHeader"; import ChatMediaPreviewModal from "@/components/messages/ChatMediaPreviewModal"; import ChatMessageItem, { MediaPreview } from "@/components/messages/ChatMessageItem"; 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"; @@ -16,7 +17,7 @@ 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 { getMessageTimestamp, mergeDisplayMessages, uniqueMessagesById } from "@/lib/messages/chatUtils"; import { formatDayDivider, getMessagePreviewText, getReplyToId } from "@/lib/messages/messageUtils"; import wsManager from "@/lib/websocketManager"; import ScrollableMessageList, { ScrollableMessageListInner } from "@/components/ScrollableMessageList"; @@ -34,6 +35,8 @@ type MessageActionsAnchor = { const RECENT_REACTIONS_KEY = "eusrr_recent_reactions"; const MAX_RECENT_REACTIONS = 5; +const PENDING_MESSAGE_DELAY_MS = 8000; +const MARK_READ_DEBOUNCE_MS = 300; const ALL_REACTIONS = [ "👍", "❤️", "😂", "🔥", "👏", "🎉", "😊", "😉", "😁", "🤝", "🙏", "😮", "😢", "😡", "💯", "✅", "👀", "🤔", "😍", "😎", @@ -62,6 +65,7 @@ export default function MessageDialogPage() { const [chat, setChat] = useState(null); const [messages, setMessages] = useState([]); + const [pendingMessages, setPendingMessages] = useState([]); const [loading, setLoading] = useState(true); const [messagesLoading, setMessagesLoading] = useState(false); const [error, setError] = useState(null); @@ -71,6 +75,7 @@ export default function MessageDialogPage() { 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)); @@ -100,6 +105,13 @@ export default function MessageDialogPage() { const [anchorRetryTick, setAnchorRetryTick] = useState(0); const hasMoreNewerRef = useRef(false); const fallbackSyncInFlightRef = useRef(false); + const pendingTimersRef = useRef>(new Map()); + const pendingMessagesRef = useRef([]); + const localMessageSequenceRef = useRef(0); + const markReadTimerRef = useRef(null); + const markReadInFlightRef = useRef(false); + const queuedMarkReadIdRef = useRef(null); + const lastConfirmedMarkReadIdRef = useRef(0); // Локальное состояние для настроек чата const [isPinned, setIsPinned] = useState(false); @@ -118,7 +130,30 @@ export default function MessageDialogPage() { } }, [chat]); - const messagesById = useMemo(() => new Map(messages.map((m) => [m.id, m])), [messages]); + const displayMessages = useMemo(() => mergeDisplayMessages(messages, pendingMessages), [messages, pendingMessages]); + const messagesById = useMemo(() => new Map(displayMessages.map((m) => [m.id, m])), [displayMessages]); + + useEffect(() => { + pendingMessagesRef.current = pendingMessages; + }, [pendingMessages]); + + useEffect(() => { + lastConfirmedMarkReadIdRef.current = Math.max( + lastConfirmedMarkReadIdRef.current, + chat?.last_read_message_id ?? 0, + ); + }, [chat?.last_read_message_id]); + + useEffect(() => { + if (markReadTimerRef.current) { + clearTimeout(markReadTimerRef.current); + markReadTimerRef.current = null; + } + + markReadInFlightRef.current = false; + queuedMarkReadIdRef.current = null; + lastConfirmedMarkReadIdRef.current = chat?.last_read_message_id ?? 0; + }, [chatId, chat?.last_read_message_id]); // Определяем текущий membership и права на отправку сообщений const myMembership = useMemo(() => { @@ -148,6 +183,237 @@ export default function MessageDialogPage() { () => uniqueEmoji(recentReactions).slice(0, MAX_RECENT_REACTIONS), [recentReactions] ); + const selectedActionReaders = selectedActionMessage?.read_by || []; + const sendingComposer = Boolean(editingMessageId && sending); + + const clearPendingTimer = useCallback((localId?: string | null) => { + if (!localId) return; + + const timer = pendingTimersRef.current.get(localId); + if (timer) { + clearTimeout(timer); + pendingTimersRef.current.delete(localId); + } + }, []); + + const markPendingAsDelayed = useCallback((localId: string) => { + setPendingMessages((prev) => + prev.map((message) => + message.local_id === localId && message.send_state === "pending" + ? { ...message, send_state: "delayed" } + : message + ) + ); + }, []); + + const schedulePendingTimer = useCallback((localId: string) => { + clearPendingTimer(localId); + + const timer = setTimeout(() => { + pendingTimersRef.current.delete(localId); + markPendingAsDelayed(localId); + }, PENDING_MESSAGE_DELAY_MS); + + pendingTimersRef.current.set(localId, timer); + }, [clearPendingTimer, markPendingAsDelayed]); + + const removePendingMessage = useCallback((localId?: string | null) => { + if (!localId) return; + + clearPendingTimer(localId); + setPendingMessages((prev) => prev.filter((message) => message.local_id !== localId)); + }, [clearPendingTimer]); + + const clearAllPendingTimers = useCallback(() => { + pendingTimersRef.current.forEach((timer) => clearTimeout(timer)); + pendingTimersRef.current.clear(); + }, []); + + const flushMarkRead = useCallback(async () => { + if (!chatId || Number.isNaN(chatId) || markReadInFlightRef.current) { + return; + } + + const targetMessageId = queuedMarkReadIdRef.current; + if (typeof targetMessageId !== "number") { + return; + } + + if (targetMessageId <= lastConfirmedMarkReadIdRef.current) { + queuedMarkReadIdRef.current = null; + return; + } + + queuedMarkReadIdRef.current = null; + markReadInFlightRef.current = true; + + try { + await apiClient.markChatAsRead(chatId, targetMessageId); + lastConfirmedMarkReadIdRef.current = Math.max( + lastConfirmedMarkReadIdRef.current, + targetMessageId, + ); + setChat((prev) => + prev + ? { + ...prev, + last_read_message_id: Math.max(prev.last_read_message_id ?? 0, targetMessageId), + } + : prev + ); + } catch (error) { + console.error("Ошибка отложенной отметки прочтения:", error); + queuedMarkReadIdRef.current = Math.max( + queuedMarkReadIdRef.current ?? 0, + targetMessageId, + ); + } finally { + markReadInFlightRef.current = false; + + if ( + queuedMarkReadIdRef.current !== null && + queuedMarkReadIdRef.current > lastConfirmedMarkReadIdRef.current && + !markReadTimerRef.current + ) { + markReadTimerRef.current = setTimeout(() => { + markReadTimerRef.current = null; + void flushMarkRead(); + }, MARK_READ_DEBOUNCE_MS); + } + } + }, [chatId]); + + const scheduleMarkRead = useCallback((messageId?: number | null) => { + if (!chatId || typeof messageId !== "number") { + return; + } + + const nextTarget = Math.max(queuedMarkReadIdRef.current ?? 0, messageId); + if (nextTarget <= lastConfirmedMarkReadIdRef.current) { + return; + } + + queuedMarkReadIdRef.current = nextTarget; + + if (markReadInFlightRef.current) { + return; + } + + if (markReadTimerRef.current) { + clearTimeout(markReadTimerRef.current); + } + + markReadTimerRef.current = setTimeout(() => { + markReadTimerRef.current = null; + void flushMarkRead(); + }, MARK_READ_DEBOUNCE_MS); + }, [chatId, flushMarkRead]); + + const getCurrentUserDisplayName = useCallback(() => { + if (!user) return "Вы"; + + const fullName = [user.last_name, user.first_name, user.patronymic].filter(Boolean).join(" ").trim(); + return fullName || user.email || "Вы"; + }, [user]); + + const guessAttachmentType = useCallback((file: File): string => { + const mime = (file.type || "").toLowerCase(); + if (mime.startsWith("image/")) return "image"; + if (mime.startsWith("video/")) return "video"; + if (mime.startsWith("audio/")) return "audio"; + return "file"; + }, []); + + const getAttachmentSignature = useCallback((message: Message) => { + return (message.attachments || []) + .map((attachment) => `${attachment.file_name}|${attachment.file_size ?? 0}|${attachment.mime_type ?? ""}`) + .sort() + .join("||"); + }, []); + + const findMatchingPendingLocalId = useCallback((confirmedMessage: Message) => { + if (!user?.id || confirmedMessage.author_id !== user.id) { + return null; + } + + const confirmedReplyToId = getReplyToId(confirmedMessage) ?? null; + const confirmedAttachmentSignature = getAttachmentSignature(confirmedMessage); + const confirmedTimestamp = getMessageTimestamp(confirmedMessage); + + const candidates = pendingMessagesRef.current + .filter((pending) => pending.author_id === user.id) + .filter((pending) => pending.content === confirmedMessage.content) + .filter((pending) => (getReplyToId(pending) ?? null) === confirmedReplyToId) + .filter((pending) => getAttachmentSignature(pending) === confirmedAttachmentSignature) + .map((pending) => ({ + localId: pending.local_id ?? null, + diff: Math.abs(getMessageTimestamp(pending) - confirmedTimestamp), + })) + .filter((candidate) => Boolean(candidate.localId)) + .sort((left, right) => left.diff - right.diff); + + const matchedCandidate = candidates[0]; + if (!matchedCandidate) { + return null; + } + + if (confirmedTimestamp > 0 && matchedCandidate.diff > 10 * 60 * 1000) { + return null; + } + + return matchedCandidate.localId; + }, [getAttachmentSignature, user?.id]); + + const removePendingMessageByServerMessage = useCallback((confirmedMessage: Message) => { + const localId = findMatchingPendingLocalId(confirmedMessage); + if (!localId) { + return; + } + + removePendingMessage(localId); + }, [findMatchingPendingLocalId, removePendingMessage]); + + const buildOptimisticMessage = useCallback((text: string, files: File[], localId: string): Message => { + localMessageSequenceRef.current += 1; + const now = Date.now() + localMessageSequenceRef.current; + + return { + id: -now, + chat: chatId, + local_id: localId, + author_id: user?.id, + author_name: getCurrentUserDisplayName(), + avatar: user?.avatar, + content: text, + is_read: false, + send_state: "pending", + is_optimistic: true, + created_at: new Date(now).toISOString(), + created_ts: now, + has_attachments: files.length > 0, + attachments: files.map((file, index) => ({ + id: -(now * 100 + index), + file_name: file.name, + file_type: guessAttachmentType(file), + file_url: "", + file_size: file.size, + mime_type: file.type, + is_local: true, + })), + reply_to_id: replyTo?.id, + }; + }, [chatId, getCurrentUserDisplayName, guessAttachmentType, replyTo?.id, user?.avatar, user?.id]); + + const resetComposerState = useCallback(() => { + setEditingMessageId(null); + setMessageText(""); + setReplyTo(null); + setExpandedReplyActionForId(null); + setAttachedFiles([]); + if (fileInputRef.current) { + fileInputRef.current.value = ""; + } + }, []); // WebSocket для real-time обновлений const { isConnected, sendTyping, handlers } = useWebSocket({ @@ -217,23 +483,17 @@ export default function MessageDialogPage() { const response = await apiClient.getChatMessages(chatId, { limit: options.limit ?? 20, after_id: newestMessage.id, - mark_read: shouldMarkRead, + mark_read: false, }); const newerMessages = response.messages || []; if (newerMessages.length > 0) { + newerMessages.forEach(removePendingMessageByServerMessage); 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 - ); + scheduleMarkRead(lastLoadedMessage.id); } if (shouldMarkRead && options.scrollOnSync) { @@ -249,7 +509,7 @@ export default function MessageDialogPage() { } finally { fallbackSyncInFlightRef.current = false; } - }, [chatId, isNearBottom, messages, messagesLoading, scrollToBottom]); + }, [chatId, isNearBottom, messages, messagesLoading, removePendingMessageByServerMessage, scheduleMarkRead, scrollToBottom]); // Обновление reactions_summary для конкретного сообщения const updateMessageReactionsSummary = useCallback(( @@ -272,6 +532,7 @@ export default function MessageDialogPage() { const newMsg = data.message; const isMyMessage = newMsg.author_id === user?.id; const wasNearBottom = isNearBottom(); + removePendingMessageByServerMessage(newMsg); setMessages(prev => { // Проверяем, нет ли уже такого сообщения (дедупликация) @@ -308,9 +569,7 @@ export default function MessageDialogPage() { // 2. И я был внизу (активно читал) // Прагматичный подход: загружено = прочитано (для сообщений в области видимости) if (!isMyMessage && wasNearBottom && chatId) { - apiClient.markChatAsRead(chatId, newMsg.id).catch(err => { - console.error('Ошибка автоотметки WebSocket сообщения:', err); - }); + scheduleMarkRead(newMsg.id); } // Автопрокрутка вниз если: @@ -399,6 +658,10 @@ export default function MessageDialogPage() { } if (readerUserId === null || readerUserId === user?.id) { + lastConfirmedMarkReadIdRef.current = Math.max( + lastConfirmedMarkReadIdRef.current, + lastReadMessageId, + ); setChat((prev) => prev ? { @@ -425,7 +688,7 @@ export default function MessageDialogPage() { handlers.current.onError = (error) => { console.error('❌ WebSocket error:', error); }; - }, [handlers, user?.id, chatId, isNearBottom, scrollToBottom, syncLatestMessages, updateMessageReactionsSummary]); + }, [handlers, user?.id, chatId, isNearBottom, removePendingMessageByServerMessage, scheduleMarkRead, scrollToBottom, syncLatestMessages, updateMessageReactionsSummary]); // Очистка таймаута при размонтировании useEffect(() => { @@ -436,8 +699,12 @@ export default function MessageDialogPage() { if (floatingDateTimeoutRef.current) { clearTimeout(floatingDateTimeoutRef.current); } + if (markReadTimerRef.current) { + clearTimeout(markReadTimerRef.current); + } + clearAllPendingTimers(); }; - }, []); + }, [clearAllPendingTimers]); useLayoutEffect(() => { if (typeof window === "undefined" || !window.history) return; @@ -542,7 +809,9 @@ export default function MessageDialogPage() { useEffect(() => { async function loadMessages() { if (!chatId || Number.isNaN(chatId)) { + clearAllPendingTimers(); setMessages([]); + setPendingMessages([]); setHasMoreOlder(false); setHasMoreNewer(false); setInitialAnchorId(null); @@ -576,8 +845,10 @@ export default function MessageDialogPage() { const aroundMessages = around.messages || []; if (aroundMessages.length > 0) { + clearAllPendingTimers(); const normalized = uniqueMessagesById(aroundMessages); setMessages(normalized); + setPendingMessages([]); // fallback на случай, если API не вернул флаг has_more_before setHasMoreOlder( typeof around.has_more_before === "boolean" ? around.has_more_before : normalized.length >= 50 @@ -587,8 +858,10 @@ export default function MessageDialogPage() { setInitialAnchorIndex(typeof around.anchor_index === "number" ? around.anchor_index : null); setAllowOneOlderProbe(Boolean(around.anchor_id)); } else { + clearAllPendingTimers(); const response = await apiClient.getChatMessages(chatId, { limit: 50 }); setMessages(uniqueMessagesById(response.messages || [])); + setPendingMessages([]); setHasMoreOlder(Boolean(response.has_more)); setHasMoreNewer(false); setInitialAnchorId(null); @@ -604,7 +877,9 @@ export default function MessageDialogPage() { const errorMessage = error instanceof Error ? error.message : String(error); if (errorMessage.includes("403")) { setError("Нет доступа к этому чату"); + clearAllPendingTimers(); setMessages([]); + setPendingMessages([]); } else { console.error("Ошибка загрузки сообщений:", error); } @@ -623,13 +898,13 @@ export default function MessageDialogPage() { } loadMessages(); - }, [chatId, userLoading, user, user?.id]); + }, [chatId, clearAllPendingTimers, userLoading, user, user?.id]); // Скролл к якорю или вниз после загрузки сообщений // Anchor scroll используется при переходе по прямой ссылке на сообщение (например, из уведомления) useEffect(() => { const component = messagesViewportRef.current; - if (messagesLoading || !component || !component.containerRef?.current || messages.length === 0 || initialScrolledRef.current) return; + if (messagesLoading || !component || !component.containerRef?.current || displayMessages.length === 0 || initialScrolledRef.current) return; const viewport = component.containerRef.current; @@ -686,7 +961,7 @@ export default function MessageDialogPage() { anchorOlderLoadAttemptsRef.current = 0; }, [ messagesLoading, - messages, + displayMessages, initialAnchorId, initialAnchorIndex, anchorRetryTick, @@ -745,23 +1020,17 @@ export default function MessageDialogPage() { const response = await apiClient.getChatMessages(chatId, { limit: 10, // Ограничено для минимальной погрешности автоотметки (было 40) after_id: newestMessage.id, - mark_read: shouldMarkRead, + mark_read: false, }); const newerMessages = response.messages || []; if (newerMessages.length > 0) { + newerMessages.forEach(removePendingMessageByServerMessage); 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 - ); + scheduleMarkRead(lastLoadedMessage.id); } // НЕ делаем автоскролл - ScrollableMessageList сам решит: @@ -776,7 +1045,7 @@ export default function MessageDialogPage() { } finally { setLoadingNewer(false); } - }, [chatId, hasMoreNewer, isNearBottom, loadingNewer, messages, messagesLoading]); + }, [chatId, hasMoreNewer, isNearBottom, loadingNewer, messages, messagesLoading, removePendingMessageByServerMessage, scheduleMarkRead]); useChatFallbackSync({ chatId: chatId || null, @@ -840,15 +1109,22 @@ export default function MessageDialogPage() { // Показ кнопки scroll-to-bottom когда не внизу const isAtBottom = isNearBottom(); setShowScrollToBottom(!isAtBottom); + + if (isAtBottom && !hasMoreNewer && messages.length > 0) { + const latestLoadedMessage = messages[messages.length - 1]; + scheduleMarkRead(latestLoadedMessage.id); + } }; viewport.addEventListener("scroll", onScroll, { passive: true }); return () => viewport.removeEventListener("scroll", onScroll); - }, [chatId, hasMoreOlder, hasMoreNewer, isNearBottom, loadNewerMessages, loadOlderMessages, loadingOlder, loadingNewer, messagesLoading, messages]); + }, [chatId, displayMessages, hasMoreOlder, hasMoreNewer, isNearBottom, loadNewerMessages, loadOlderMessages, loadingOlder, loadingNewer, messages, messagesLoading, scheduleMarkRead]); const handleSend = async () => { const text = messageText.trim(); - if (!chatId || sending) return; + let optimisticLocalId: string | null = null; + + if (!chatId || (editingMessageId && sending)) return; if (editingMessageId) { if (!text) return; } else if (!text && attachedFiles.length === 0) { @@ -859,24 +1135,38 @@ export default function MessageDialogPage() { const wasNearBottom = isNearBottom(); try { - setSending(true); if (editingMessageId) { + setSending(true); const updated = await apiClient.updateMessage(editingMessageId, text); setMessages((prev) => prev.map((m) => (m.id === editingMessageId ? { ...m, ...updated } : m))); + resetComposerState(); } 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])); - } + 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 optimisticMessage = buildOptimisticMessage(text, files, localId); - setEditingMessageId(null); - setMessageText(""); - setReplyTo(null); - setExpandedReplyActionForId(null); - setAttachedFiles([]); - if (fileInputRef.current) { - fileInputRef.current.value = ""; + setPendingMessages((prev) => [...prev, optimisticMessage]); + schedulePendingTimer(localId); + resetComposerState(); + + const sent = files.length + ? await apiClient.sendMessageWithFiles(chatId, text, files, replyToId) + : await apiClient.sendMessage(chatId, text, replyToId); + + removePendingMessage(localId); + setMessages((prev) => uniqueMessagesById([...prev, sent])); + setHasMoreNewer(false); + + if (wasNearBottom) { + requestAnimationFrame(() => { + scrollToBottom(false); + }); + } + return; } // Автоскролл вниз ТОЛЬКО если был внизу при отправке @@ -888,8 +1178,20 @@ export default function MessageDialogPage() { } } catch (e) { console.error("Ошибка отправки сообщения:", e); + if (!editingMessageId && optimisticLocalId) { + clearPendingTimer(optimisticLocalId); + setPendingMessages((prev) => + prev.map((message) => + message.local_id === optimisticLocalId + ? { ...message, send_state: "failed" } + : message + ) + ); + } } finally { - setSending(false); + if (editingMessageId) { + setSending(false); + } } }; @@ -922,6 +1224,7 @@ export default function MessageDialogPage() { setEditingMessageId(null); setReplyTo({ id: message.id, author, preview }); setExpandedReplyActionForId(null); + setIsReadersModalOpen(false); setActionsMenuAnchor(null); requestAnimationFrame(focusMessageInput); }; @@ -980,6 +1283,7 @@ export default function MessageDialogPage() { } setExpandedReplyActionForId(null); + setIsReadersModalOpen(false); setActionsMenuAnchor(null); } catch (e) { console.error("Ошибка удаления сообщения:", e); @@ -989,10 +1293,12 @@ export default function MessageDialogPage() { const handleToggleMessageActions = useCallback((messageId: number, anchor: MessageActionsAnchor) => { setExpandedReplyActionForId((prev) => { if (prev === messageId) { + setIsReadersModalOpen(false); setActionsMenuAnchor(null); return null; } + setIsReadersModalOpen(false); setReactionPickerForMessageId(null); setShowComposerEmojiPicker(false); setActionsMenuAnchor(anchor); @@ -1173,14 +1479,14 @@ export default function MessageDialogPage() { > {messagesLoading ? (

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

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

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

) : (
{loadingOlder ? (

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

) : null} - {messages.map((message) => { + {displayMessages.map((message) => { const replyToId = getReplyToId(message); const repliedMessage = replyToId ? messagesById.get(replyToId) : null; @@ -1191,8 +1497,8 @@ export default function MessageDialogPage() { currentUserId={user?.id} repliedMessage={repliedMessage} isActionsOpen={expandedReplyActionForId === message.id} - canManage={canManageMessage(message)} - canReply={!message.is_deleted} + canManage={!message.is_optimistic && canManageMessage(message)} + canReply={!message.is_deleted && !message.is_optimistic} brokenMedia={brokenMedia} useOriginalImage={useOriginalImage} onToggleActions={handleToggleMessageActions} @@ -1248,7 +1554,7 @@ export default function MessageDialogPage() { replyTo={replyTo} attachedFiles={attachedFiles} messageText={messageText} - sending={sending} + sending={sendingComposer} showEmojiPicker={showComposerEmojiPicker} allReactions={ALL_REACTIONS} fileInputRef={fileInputRef} @@ -1293,6 +1599,7 @@ export default function MessageDialogPage() { {expandedReplyActionForId && actionsMenuAnchor && selectedActionMessage ? ( { 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 ? ( + + + ); + } + + if (sendState === "delayed") { + return ( + + + + ); + } + + if (sendState === "failed") { + return ( + + + + ); + } + const Icon = isRead ? CheckCheck : Check; return ( @@ -83,6 +107,7 @@ export default function ChatMessageItem({ ); const hasActions = canReply || canManage; const isRead = Boolean(message.is_read); + const sendState = message.send_state; return (
@@ -146,6 +171,19 @@ export default function ChatMessageItem({ {message.attachments && message.attachments.length > 0 ? (
{message.attachments.map((attachment) => { + if (attachment.is_local) { + return ( +
+ + {attachment.file_name} + {formatFileSize(attachment.file_size)} +
+ ); + } + const fileUrl = resolveMediaUrl(attachment.file_url); if (isImageAttachment(attachment)) { @@ -284,7 +322,7 @@ export default function ChatMessageItem({

{messageTime(message)}

- {isMine ? : null} + {isMine ? : null}
{Object.keys(message.reactions_summary || {}).length > 0 ? ( diff --git a/frontend/src/components/messages/MessageActionsMenu.tsx b/frontend/src/components/messages/MessageActionsMenu.tsx index a97d8d58..905e691e 100644 --- a/frontend/src/components/messages/MessageActionsMenu.tsx +++ b/frontend/src/components/messages/MessageActionsMenu.tsx @@ -1,17 +1,19 @@ "use client"; -import { Pencil, Reply, Smile, Trash2 } from "lucide-react"; +import { CheckCheck, Pencil, Reply, Smile, Trash2 } from "lucide-react"; import type { Message } from "@/types/api"; type MessageActionsMenuProps = { anchor: { x: number; y: number }; + currentUserId?: number; message: Message; canReply: boolean; canManage: boolean; recentReactions: string[]; onQuickReact: (emoji: string) => void; onOpenReactionPicker: () => void; + onShowAllReaders?: () => void; onReply: () => void; onEdit: () => void; onDelete: () => void; @@ -19,34 +21,49 @@ type MessageActionsMenuProps = { export default function MessageActionsMenu({ anchor, + currentUserId, message, canReply, canManage, recentReactions, onQuickReact, onOpenReactionPicker, + onShowAllReaders, onReply, onEdit, onDelete, }: MessageActionsMenuProps) { + const isMine = Boolean( + currentUserId && + (message.author_id === currentUserId || message.author?.id === currentUserId || message.sender?.id === currentUserId) + ); + const readers = message.read_by || []; + const previewReaders = readers.slice(0, 3); + const hasMoreReaders = readers.length > previewReaders.length; + return (
{canReply ? ( -
+
+
+ Быстрые реакции +
+ +
{recentReactions.map((emoji) => ( +
) : null} - {canReply ? ( - + {isMine ? ( +
+
+ + Прочитали +
+ + {readers.length > 0 ? ( + <> +
+ {previewReaders.map((reader) => ( +
+ {reader.name} +
+ ))} +
+ + {onShowAllReaders ? ( + + ) : null} + + ) : ( +

Пока никто не дочитал это сообщение.

+ )} +
) : null} - {canManage ? ( - <> +
+ {canReply ? ( + + ) : null} + + {canManage ? ( + ) : null} + {canManage ? ( - - ) : null} + ) : null} +
); } \ No newline at end of file diff --git a/frontend/src/components/messages/MessageReadersModal.tsx b/frontend/src/components/messages/MessageReadersModal.tsx new file mode 100644 index 00000000..58bee6bc --- /dev/null +++ b/frontend/src/components/messages/MessageReadersModal.tsx @@ -0,0 +1,51 @@ +"use client"; + +import { CheckCheck } from "lucide-react"; + +import { Modal } from "@/components/ui"; +import type { MessageReader } from "@/types/api"; + +type MessageReadersModalProps = { + isOpen: boolean; + onClose: () => void; + readers: MessageReader[]; +}; + +export default function MessageReadersModal({ isOpen, onClose, readers }: MessageReadersModalProps) { + return ( + +
+
+
+ + + +
+

Полный список прочитавших

+

+ {readers.length === 1 ? "1 сотрудник" : `${readers.length} сотрудников`} +

+
+
+
+ + {readers.length > 0 ? ( +
+ {readers.map((reader) => ( +
+ {reader.name} +
+ ))} +
+ ) : ( +
+ Пока никто не дочитал это сообщение. +
+ )} +
+
+ ); +} \ No newline at end of file diff --git a/frontend/src/hooks/useWebSocket.ts b/frontend/src/hooks/useWebSocket.ts index d6b33ad2..52948aab 100644 --- a/frontend/src/hooks/useWebSocket.ts +++ b/frontend/src/hooks/useWebSocket.ts @@ -36,6 +36,8 @@ interface UseWebSocketOptions { }) => void; } +const TYPING_THROTTLE_MS = 2000; + export function useWebSocket({ chatId, autoConnect = true, @@ -51,6 +53,7 @@ export function useWebSocket({ const reconnectAttemptsRef = useRef(0); const shouldReconnectRef = useRef(autoConnect); const connectRef = useRef<() => void>(() => {}); + const lastTypingSentAtRef = useRef(0); const handlers = useRef({ onMessage: () => {}, @@ -192,15 +195,24 @@ export function useWebSocket({ reconnectAttemptsRef.current = 0; setReconnectAttempts(0); isConnectingRef.current = false; + lastTypingSentAtRef.current = 0; }, [chatId]); const sendTyping = useCallback(() => { - if (wsRef.current?.readyState === WebSocket.OPEN) { - wsRef.current.send(JSON.stringify({ - action: 'typing' - })); + if (!chatId || wsRef.current?.readyState !== WebSocket.OPEN) { + return; } - }, []); + + const now = Date.now(); + if (now - lastTypingSentAtRef.current < TYPING_THROTTLE_MS) { + return; + } + + lastTypingSentAtRef.current = now; + wsRef.current.send(JSON.stringify({ + action: 'typing' + })); + }, [chatId]); const sendMessage = useCallback((message: Record) => { if (wsRef.current?.readyState === WebSocket.OPEN) { diff --git a/frontend/src/lib/messages/chatUtils.ts b/frontend/src/lib/messages/chatUtils.ts index f8c918f7..f7353000 100644 --- a/frontend/src/lib/messages/chatUtils.ts +++ b/frontend/src/lib/messages/chatUtils.ts @@ -121,4 +121,38 @@ export function uniqueMessagesById(items: Message[]): Message[] { map.set(message.id, message); }); return Array.from(map.values()); +} + +export function getMessageTimestamp(message: Message): number { + if (typeof message.created_ts === "number") { + return message.created_ts; + } + + if (message.created_at) { + const value = new Date(message.created_at).getTime(); + if (!Number.isNaN(value)) { + return value; + } + } + + return 0; +} + +export function mergeDisplayMessages(serverMessages: Message[], pendingMessages: Message[]): Message[] { + const combined = [...serverMessages, ...pendingMessages].map((message, index) => ({ message, index })); + + combined.sort((left, right) => { + const timestampDiff = getMessageTimestamp(left.message) - getMessageTimestamp(right.message); + if (timestampDiff !== 0) { + return timestampDiff; + } + + if (left.message.is_optimistic !== right.message.is_optimistic) { + return left.message.is_optimistic ? 1 : -1; + } + + return left.index - right.index; + }); + + return combined.map(({ message }) => message); } \ No newline at end of file diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts index db785793..068559e4 100644 --- a/frontend/src/types/api.ts +++ b/frontend/src/types/api.ts @@ -507,6 +507,7 @@ export interface ChatUserSettings { export interface Message { id: number; chat?: number; + local_id?: string | null; sender?: User; author?: User; author_id?: number; @@ -514,6 +515,10 @@ export interface Message { avatar?: string; content: string; is_read?: boolean; + read_count?: number; + read_by?: MessageReader[]; + send_state?: 'pending' | 'delayed' | 'failed'; + is_optimistic?: boolean; created_at?: string; created?: string; created_ts?: number; @@ -531,6 +536,11 @@ export interface Message { }>; } +export interface MessageReader { + id: number; + name: string; +} + export interface MessageAttachment { id: number; file_name: string; @@ -541,6 +551,7 @@ export interface MessageAttachment { width?: number; height?: number; thumbnail?: string | null; + is_local?: boolean; } // Calendar types (django-scheduler) From 6bd66e7855ee264070bd6bdce451d0d19627c6d4 Mon Sep 17 00:00:00 2001 From: lizerk Date: Fri, 3 Apr 2026 11:16:26 +0300 Subject: [PATCH 03/19] Refine chat actions menu styling --- .../messages/MessageActionsMenu.tsx | 37 +++++++++---------- .../messages/MessageReadersModal.tsx | 10 ++--- 2 files changed, 22 insertions(+), 25 deletions(-) diff --git a/frontend/src/components/messages/MessageActionsMenu.tsx b/frontend/src/components/messages/MessageActionsMenu.tsx index 905e691e..e905b671 100644 --- a/frontend/src/components/messages/MessageActionsMenu.tsx +++ b/frontend/src/components/messages/MessageActionsMenu.tsx @@ -44,7 +44,7 @@ export default function MessageActionsMenu({ return (
{canReply ? ( -
-
- Быстрые реакции +
+
+ Быстрые реакции
-
+
{recentReactions.map((emoji) => ( ) : null} @@ -120,12 +117,12 @@ export default function MessageActionsMenu({
) : null} -
+
{canReply ? (
) : null} - {isMine ? ( -
-
- - Прочитали -
+
+ {isMine && onShowAllReaders ? ( + <> + +
+ + ) : null} - {readers.length > 0 ? ( - <> -
- {previewReaders.map((reader) => ( -
- {reader.name} -
- ))} -
- - {onShowAllReaders ? ( - - ) : null} - - ) : ( -

Пока никто не дочитал это сообщение.

- )} -
- ) : null} - -
{canReply ? ( ) : null} @@ -135,7 +111,7 @@ export default function MessageActionsMenu({ onClick={onEdit} className="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-gray-700 transition hover:bg-gray-50" > - + Редактировать ) : null} @@ -146,7 +122,7 @@ export default function MessageActionsMenu({ onClick={onDelete} className="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-red-600 transition hover:bg-red-50" > - + Удалить ) : null} diff --git a/frontend/src/components/messages/MessageReadersModal.tsx b/frontend/src/components/messages/MessageReadersModal.tsx index af9a66d2..160f0b41 100644 --- a/frontend/src/components/messages/MessageReadersModal.tsx +++ b/frontend/src/components/messages/MessageReadersModal.tsx @@ -15,18 +15,9 @@ export default function MessageReadersModal({ isOpen, onClose, readers }: Messag return (
-
-
- - - -
-

Полный список прочитавших

-

- {readers.length === 1 ? "1 сотрудник" : `${readers.length} сотрудников`} -

-
-
+
+ +

{readers.length === 1 ? "Сообщение прочитал 1 сотрудник" : `Сообщение прочитали ${readers.length} сотрудников`}

{readers.length > 0 ? ( From c4253f6a78ad8ef158e57376a08438fcdc12ca62 Mon Sep 17 00:00:00 2001 From: lizerk Date: Fri, 3 Apr 2026 11:43:29 +0300 Subject: [PATCH 05/19] Add readers avatars and emoji picker --- backend/communications/serialization.py | 21 +++++--- .../communications/test_communications_api.py | 11 ++++- frontend/package-lock.json | 22 +++++++++ frontend/package.json | 1 + frontend/src/app/messages/[chatId]/page.tsx | 4 +- .../messages/MessageReadersModal.tsx | 39 ++++++++++++++- .../messages/ReactionPickerModal.tsx | 49 ++++++++++--------- frontend/src/types/api.ts | 1 + 8 files changed, 114 insertions(+), 34 deletions(-) diff --git a/backend/communications/serialization.py b/backend/communications/serialization.py index 1464b7f9..94db40dc 100644 --- a/backend/communications/serialization.py +++ b/backend/communications/serialization.py @@ -73,6 +73,19 @@ def _is_message_read(m) -> bool: ).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 _get_message_read_by(m) -> list[dict]: """Список участников, которые дочитали сообщение.""" chat = getattr(m, "chat", None) @@ -105,6 +118,7 @@ def _get_message_read_by(m) -> list[dict]: { "id": user.id, "name": user.get_full_name() or user.username, + "avatar": _get_user_avatar_url(user), } ) @@ -127,12 +141,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 = { diff --git a/backend/tests/api/v1/communications/test_communications_api.py b/backend/tests/api/v1/communications/test_communications_api.py index ae83337c..508b1f47 100644 --- a/backend/tests/api/v1/communications/test_communications_api.py +++ b/backend/tests/api/v1/communications/test_communications_api.py @@ -203,6 +203,9 @@ class TestChatViewSet: 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, @@ -223,7 +226,13 @@ class TestChatViewSet: 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()}] + 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): """Пометка чата как прочитанного""" 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/messages/[chatId]/page.tsx b/frontend/src/app/messages/[chatId]/page.tsx index e1c98dab..a797b86b 100644 --- a/frontend/src/app/messages/[chatId]/page.tsx +++ b/frontend/src/app/messages/[chatId]/page.tsx @@ -41,7 +41,7 @@ const ALL_REACTIONS = [ "👍", "❤️", "😂", "🔥", "👏", "🎉", "😊", "😉", "😁", "🤝", "🙏", "😮", "😢", "😡", "💯", "✅", "👀", "🤔", "😍", "😎", "🤩", "🥳", "😴", "🫡", "👌", "💪", "🙌", "🧠", "💡", "🚀", - "🎯", "⭐", "✨", "🌟", "🫶", "🤗", "😅", "🤯", "🥲", "🫠", + "🎯", "⭐", "✨", "💩", "🫶", "🤗", "😅", "🤯", "🥲", "🫠", ]; function uniqueEmoji(items: string[]): string[] { @@ -1360,6 +1360,7 @@ export default function MessageDialogPage() { 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(".reaction-picker-modal")) return; if (target.closest("[data-composer-emoji='true']")) return; setExpandedReplyActionForId(null); @@ -1628,7 +1629,6 @@ export default function MessageDialogPage() { {reactionPickerForMessageId ? ( setReactionPickerForMessageId(null)} onSelect={(emoji) => { const message = messagesById.get(reactionPickerForMessageId); diff --git a/frontend/src/components/messages/MessageReadersModal.tsx b/frontend/src/components/messages/MessageReadersModal.tsx index 160f0b41..8d4fd3a6 100644 --- a/frontend/src/components/messages/MessageReadersModal.tsx +++ b/frontend/src/components/messages/MessageReadersModal.tsx @@ -1,8 +1,10 @@ "use client"; +import Image from "next/image"; import { CheckCheck } from "lucide-react"; import { Modal } from "@/components/ui"; +import { resolveMediaUrl } from "@/lib/url"; import type { MessageReader } from "@/types/api"; type MessageReadersModalProps = { @@ -11,6 +13,22 @@ type MessageReadersModalProps = { readers: MessageReader[]; }; +function getReaderInitials(name: string): string { + const parts = name + .split(/\s+/) + .map((part) => part.trim()) + .filter(Boolean); + + if (parts.length === 0) { + return "П"; + } + + return parts + .slice(0, 2) + .map((part) => part[0]?.toUpperCase() || "") + .join("") || "П"; +} + export default function MessageReadersModal({ isOpen, onClose, readers }: MessageReadersModalProps) { return ( @@ -25,9 +43,26 @@ export default function MessageReadersModal({ isOpen, onClose, readers }: Messag {readers.map((reader) => (
- {reader.name} +
+ {reader.avatar ? ( + {reader.name} + ) : ( + {getReaderInitials(reader.name)} + )} +
+ +
+

{reader.name}

+
))}
diff --git a/frontend/src/components/messages/ReactionPickerModal.tsx b/frontend/src/components/messages/ReactionPickerModal.tsx index 9e246df9..e8d98c7c 100644 --- a/frontend/src/components/messages/ReactionPickerModal.tsx +++ b/frontend/src/components/messages/ReactionPickerModal.tsx @@ -1,36 +1,39 @@ "use client"; -import { X } from "lucide-react"; +import dynamic from "next/dynamic"; +import { EmojiStyle, SuggestionMode, Theme } from "emoji-picker-react"; + +import { Modal } from "@/components/ui"; +import emojiDataRu from "emoji-picker-react/dist/data/emojis-ru.js"; + +const EmojiPicker = dynamic(() => import("emoji-picker-react"), { ssr: false }); type ReactionPickerModalProps = { - allReactions: string[]; onClose: () => void; onSelect: (emoji: string) => void; }; -export default function ReactionPickerModal({ allReactions, onClose, onSelect }: ReactionPickerModalProps) { +export default function ReactionPickerModal({ onClose, onSelect }: ReactionPickerModalProps) { return ( -
-
-
-

Выберите реакцию

- -
-
- {allReactions.map((emoji) => ( - - ))} + +
+

Быстрый способ отреагировать на сообщение без текста.

+ +
+ onSelect(emoji.emoji)} + searchPlaceholder="Поиск смайликов" + theme={Theme.LIGHT} + emojiStyle={EmojiStyle.NATIVE} + suggestedEmojisMode={SuggestionMode.FREQUENT} + lazyLoadEmojis + previewConfig={{ showPreview: false }} + width="100%" + height={420} + />
-
+ ); } \ No newline at end of file diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts index 068559e4..43072853 100644 --- a/frontend/src/types/api.ts +++ b/frontend/src/types/api.ts @@ -539,6 +539,7 @@ export interface Message { export interface MessageReader { id: number; name: string; + avatar?: string; } export interface MessageAttachment { From c1a1c31494e7a647478e2ac4e21638c222965406 Mon Sep 17 00:00:00 2001 From: lizerk Date: Fri, 3 Apr 2026 12:06:21 +0300 Subject: [PATCH 06/19] Refine message composer styling --- .../components/messages/MessageComposer.tsx | 22 +++++++++---------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/frontend/src/components/messages/MessageComposer.tsx b/frontend/src/components/messages/MessageComposer.tsx index ac6717be..8db46b18 100644 --- a/frontend/src/components/messages/MessageComposer.tsx +++ b/frontend/src/components/messages/MessageComposer.tsx @@ -59,7 +59,7 @@ export default function MessageComposer({ onCancelReply, }: MessageComposerProps) { return ( -
+ <> {editingMessageId ? (
@@ -99,7 +99,7 @@ export default function MessageComposer({ {attachedFiles.map((file, index) => ( {file.name} -
+
{showEmojiPicker ? ( -
+
{allReactions.map((emoji) => ( @@ -177,7 +177,7 @@ export default function MessageComposer({ }} rows={1} placeholder={editingMessageId ? "Редактируйте сообщение..." : "Введите сообщение..."} - className="h-9 w-full resize-none rounded-lg border border-gray-200 bg-white px-3 py-2 pr-10 text-sm text-gray-900 outline-none ring-0 transition focus:border-sky-500 focus:ring-2 focus:ring-sky-100" + className="min-h-[42px] max-h-32 w-full resize-none rounded-[1.35rem] border border-gray-200 bg-gray-50 px-4 py-2.5 pr-12 text-sm text-gray-800 outline-none transition placeholder:text-gray-400 focus:border-sky-500 focus:bg-white focus:outline-none focus:ring-2 focus:ring-sky-100" />
@@ -185,7 +185,7 @@ export default function MessageComposer({ type="button" onClick={onSend} disabled={sending || (editingMessageId ? !messageText.trim() : (!messageText.trim() && attachedFiles.length === 0))} - className="inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-sky-500 text-white leading-none transition hover:bg-sky-600 disabled:cursor-not-allowed disabled:opacity-50" + className="inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-sky-500 text-white leading-none transition hover:bg-sky-600 focus:outline-none focus:ring-2 focus:ring-sky-100 disabled:cursor-not-allowed disabled:opacity-50" title={editingMessageId ? "Сохранить" : "Отправить"} > @@ -201,6 +201,6 @@ export default function MessageComposer({

)} -
+ ); } \ No newline at end of file From 4fb637e153cbad0b215cb63d5a446deaf8275701 Mon Sep 17 00:00:00 2001 From: lizerk Date: Fri, 3 Apr 2026 12:21:50 +0300 Subject: [PATCH 07/19] Improve message composer behavior --- .../components/messages/MessageComposer.tsx | 62 +++++++++++++++---- 1 file changed, 49 insertions(+), 13 deletions(-) diff --git a/frontend/src/components/messages/MessageComposer.tsx b/frontend/src/components/messages/MessageComposer.tsx index 8db46b18..274d11f3 100644 --- a/frontend/src/components/messages/MessageComposer.tsx +++ b/frontend/src/components/messages/MessageComposer.tsx @@ -1,8 +1,12 @@ "use client"; +import { useLayoutEffect, useState } from "react"; import type React from "react"; import { Paperclip, Send, Smile, X } from "lucide-react"; +const MIN_COMPOSER_HEIGHT = 42; +const MAX_COMPOSER_HEIGHT = 128; + type ReplyTarget = { id: number; author: string; @@ -58,6 +62,22 @@ export default function MessageComposer({ onCancelEdit, onCancelReply, }: MessageComposerProps) { + const [isExpanded, setIsExpanded] = useState(false); + const canSend = editingMessageId ? messageText.trim().length > 0 : messageText.trim().length > 0 || attachedFiles.length > 0; + + useLayoutEffect(() => { + const input = messageInputRef.current; + if (!input) return; + + input.style.height = "0px"; + const nextHeight = Math.min(input.scrollHeight, MAX_COMPOSER_HEIGHT); + const resolvedHeight = Math.max(nextHeight, MIN_COMPOSER_HEIGHT); + + input.style.height = `${resolvedHeight}px`; + input.style.overflowY = input.scrollHeight > MAX_COMPOSER_HEIGHT ? "auto" : "hidden"; + setIsExpanded(resolvedHeight > MIN_COMPOSER_HEIGHT + 2); + }, [messageText, messageInputRef]); + return ( <> {editingMessageId ? ( @@ -124,22 +144,34 @@ export default function MessageComposer({ className="hidden" onChange={onFilesChange} /> - + -
@@ -177,16 +209,20 @@ export default function MessageComposer({ }} rows={1} placeholder={editingMessageId ? "Редактируйте сообщение..." : "Введите сообщение..."} - className="min-h-[42px] max-h-32 w-full resize-none rounded-[1.35rem] border border-gray-200 bg-gray-50 px-4 py-2.5 pr-12 text-sm text-gray-800 outline-none transition placeholder:text-gray-400 focus:border-sky-500 focus:bg-white focus:outline-none focus:ring-2 focus:ring-sky-100" + aria-label={editingMessageId ? "Редактирование сообщения" : "Поле ввода сообщения"} + className={`w-full resize-none bg-transparent py-2.5 pl-11 pr-12 text-sm leading-5 text-gray-800 outline-none placeholder:text-gray-400 ${ + isExpanded ? "rounded-[1.5rem]" : "rounded-full" + }`} />
From 510085148193995d6aa0429270381f5888c93a1b Mon Sep 17 00:00:00 2001 From: lizerk Date: Fri, 3 Apr 2026 12:25:01 +0300 Subject: [PATCH 08/19] Polish mobile chat composer layout --- frontend/src/app/messages/[chatId]/page.tsx | 2 +- frontend/src/components/messages/MessageComposer.tsx | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/frontend/src/app/messages/[chatId]/page.tsx b/frontend/src/app/messages/[chatId]/page.tsx index a797b86b..d25f8390 100644 --- a/frontend/src/app/messages/[chatId]/page.tsx +++ b/frontend/src/app/messages/[chatId]/page.tsx @@ -1536,7 +1536,7 @@ export default function MessageDialogPage() {
)} -
+
{isTyping && (
diff --git a/frontend/src/components/messages/MessageComposer.tsx b/frontend/src/components/messages/MessageComposer.tsx index 274d11f3..05e50685 100644 --- a/frontend/src/components/messages/MessageComposer.tsx +++ b/frontend/src/components/messages/MessageComposer.tsx @@ -4,7 +4,7 @@ import { useLayoutEffect, useState } from "react"; import type React from "react"; import { Paperclip, Send, Smile, X } from "lucide-react"; -const MIN_COMPOSER_HEIGHT = 42; +const MIN_COMPOSER_HEIGHT = 40; const MAX_COMPOSER_HEIGHT = 128; type ReplyTarget = { @@ -145,7 +145,7 @@ export default function MessageComposer({ onChange={onFilesChange} />
From deca5a84ff4858489293ee75644e397c095052b1 Mon Sep 17 00:00:00 2001 From: lizerk Date: Fri, 3 Apr 2026 12:42:41 +0300 Subject: [PATCH 09/19] Polish mobile chat layout spacing --- frontend/next-env.d.ts | 2 +- frontend/src/app/messages/[chatId]/page.tsx | 4 ++-- frontend/src/components/AppShell.tsx | 2 +- frontend/src/components/messages/ChatDialogHeader.tsx | 2 +- frontend/src/components/messages/MessageComposer.tsx | 6 +++--- 5 files changed, 8 insertions(+), 8 deletions(-) diff --git a/frontend/next-env.d.ts b/frontend/next-env.d.ts index c4b7818f..9edff1c7 100644 --- a/frontend/next-env.d.ts +++ b/frontend/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -import "./.next/dev/types/routes.d.ts"; +import "./.next/types/routes.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/frontend/src/app/messages/[chatId]/page.tsx b/frontend/src/app/messages/[chatId]/page.tsx index d25f8390..cdb61302 100644 --- a/frontend/src/app/messages/[chatId]/page.tsx +++ b/frontend/src/app/messages/[chatId]/page.tsx @@ -1446,7 +1446,7 @@ export default function MessageDialogPage() {
) : (
-
+
{chat ? ( <> )} -
+
{isTyping && (
diff --git a/frontend/src/components/AppShell.tsx b/frontend/src/components/AppShell.tsx index fd8530b8..3856ba19 100644 --- a/frontend/src/components/AppShell.tsx +++ b/frontend/src/components/AppShell.tsx @@ -425,7 +425,7 @@ export function AppShell({ children }: AppShellProps) { return (
setIsMobileLeftNavOpen(true)} onOpenCalendar={() => setIsMobileCalendarOpen(true)} /> -
+
{children}
+
{avatar ? ( diff --git a/frontend/src/components/messages/MessageComposer.tsx b/frontend/src/components/messages/MessageComposer.tsx index 05e50685..0b52ef17 100644 --- a/frontend/src/components/messages/MessageComposer.tsx +++ b/frontend/src/components/messages/MessageComposer.tsx @@ -4,7 +4,7 @@ import { useLayoutEffect, useState } from "react"; import type React from "react"; import { Paperclip, Send, Smile, X } from "lucide-react"; -const MIN_COMPOSER_HEIGHT = 40; +const MIN_COMPOSER_HEIGHT = 38; const MAX_COMPOSER_HEIGHT = 128; type ReplyTarget = { @@ -146,7 +146,7 @@ export default function MessageComposer({ />
@@ -220,7 +220,7 @@ export default function MessageComposer({ type="button" onClick={onSend} disabled={sending || !canSend} - className="inline-flex h-[42px] w-[42px] shrink-0 items-center justify-center rounded-full bg-sky-500 text-white leading-none shadow-sm shadow-sky-200/70 transition hover:bg-sky-600 active:scale-[0.98] focus:outline-none focus:ring-2 focus:ring-sky-100 disabled:cursor-not-allowed disabled:border disabled:border-gray-200 disabled:bg-gray-100 disabled:text-gray-400 disabled:shadow-none disabled:opacity-100" + className="inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-sky-500 text-white leading-none shadow-sm shadow-sky-200/70 transition hover:bg-sky-600 active:scale-[0.98] focus:outline-none focus:ring-2 focus:ring-sky-100 disabled:cursor-not-allowed disabled:border disabled:border-gray-200 disabled:bg-gray-100 disabled:text-gray-400 disabled:shadow-none disabled:opacity-100" title={editingMessageId ? "Сохранить" : "Отправить"} aria-label={editingMessageId ? "Сохранить сообщение" : "Отправить сообщение"} > From 1b7559a296f645309dbbe8782321748d32412556 Mon Sep 17 00:00:00 2001 From: lizerk Date: Fri, 3 Apr 2026 14:04:18 +0300 Subject: [PATCH 10/19] Improve chat composer and unread jump indicator --- frontend/src/app/messages/[chatId]/page.tsx | 106 ++++++++++++++++-- .../components/messages/MessageComposer.tsx | 24 +++- 2 files changed, 119 insertions(+), 11 deletions(-) diff --git a/frontend/src/app/messages/[chatId]/page.tsx b/frontend/src/app/messages/[chatId]/page.tsx index cdb61302..ed1a0711 100644 --- a/frontend/src/app/messages/[chatId]/page.tsx +++ b/frontend/src/app/messages/[chatId]/page.tsx @@ -86,6 +86,7 @@ export default function MessageDialogPage() { const [floatingDate, setFloatingDate] = useState(null); const [showFloatingDate, setShowFloatingDate] = useState(false); const [showScrollToBottom, setShowScrollToBottom] = useState(false); + const [newMessagesBelowCount, setNewMessagesBelowCount] = useState(0); const floatingDateTimeoutRef = useRef(null); const fileInputRef = useRef(null); const messageInputRef = useRef(null); @@ -112,6 +113,7 @@ export default function MessageDialogPage() { const markReadInFlightRef = useRef(false); const queuedMarkReadIdRef = useRef(null); const lastConfirmedMarkReadIdRef = useRef(0); + const unreadBelowMessageIdsRef = useRef>(new Set()); // Локальное состояние для настроек чата const [isPinned, setIsPinned] = useState(false); @@ -153,6 +155,8 @@ export default function MessageDialogPage() { markReadInFlightRef.current = false; queuedMarkReadIdRef.current = null; lastConfirmedMarkReadIdRef.current = chat?.last_read_message_id ?? 0; + unreadBelowMessageIdsRef.current.clear(); + setNewMessagesBelowCount(0); }, [chatId, chat?.last_read_message_id]); // Определяем текущий membership и права на отправку сообщений @@ -229,6 +233,33 @@ export default function MessageDialogPage() { pendingTimersRef.current.clear(); }, []); + const resetNewMessagesBelow = useCallback(() => { + unreadBelowMessageIdsRef.current.clear(); + setNewMessagesBelowCount(0); + }, []); + + const registerNewMessagesBelow = useCallback((incomingMessages: Message[]) => { + if (!user?.id || incomingMessages.length === 0) { + return; + } + + let addedCount = 0; + + incomingMessages.forEach((message) => { + const authorId = message.author_id ?? message.author?.id ?? message.sender?.id; + if (authorId === user.id || message.id <= 0 || unreadBelowMessageIdsRef.current.has(message.id)) { + return; + } + + unreadBelowMessageIdsRef.current.add(message.id); + addedCount += 1; + }); + + if (addedCount > 0) { + setNewMessagesBelowCount((prev) => prev + addedCount); + } + }, [user?.id]); + const flushMarkRead = useCallback(async () => { if (!chatId || Number.isNaN(chatId) || markReadInFlightRef.current) { return; @@ -453,6 +484,17 @@ export default function MessageDialogPage() { return scrollHeight - scrollTop - clientHeight <= threshold; }, []); + const syncScrollToBottomState = useCallback(() => { + const isAtBottom = isNearBottom(); + setShowScrollToBottom(!isAtBottom); + + if (isAtBottom) { + resetNewMessagesBelow(); + } + + return isAtBottom; + }, [isNearBottom, resetNewMessagesBelow]); + // Автопрокрутка вниз const scrollToBottom = useCallback((smooth = false) => { const component = messagesViewportRef.current; @@ -533,6 +575,11 @@ export default function MessageDialogPage() { const isMyMessage = newMsg.author_id === user?.id; const wasNearBottom = isNearBottom(); removePendingMessageByServerMessage(newMsg); + + if (!isMyMessage && !wasNearBottom) { + registerNewMessagesBelow([newMsg]); + setShowScrollToBottom(true); + } setMessages(prev => { // Проверяем, нет ли уже такого сообщения (дедупликация) @@ -688,7 +735,7 @@ export default function MessageDialogPage() { handlers.current.onError = (error) => { console.error('❌ WebSocket error:', error); }; - }, [handlers, user?.id, chatId, isNearBottom, removePendingMessageByServerMessage, scheduleMarkRead, scrollToBottom, syncLatestMessages, updateMessageReactionsSummary]); + }, [handlers, user?.id, chatId, isNearBottom, registerNewMessagesBelow, removePendingMessageByServerMessage, scheduleMarkRead, scrollToBottom, syncLatestMessages, updateMessageReactionsSummary]); // Очистка таймаута при размонтировании useEffect(() => { @@ -818,6 +865,7 @@ export default function MessageDialogPage() { setInitialAnchorIndex(null); setAllowOneOlderProbe(false); initialScrolledRef.current = false; + resetNewMessagesBelow(); return; } @@ -857,16 +905,35 @@ export default function MessageDialogPage() { setInitialAnchorId(around.anchor_id ?? null); setInitialAnchorIndex(typeof around.anchor_index === "number" ? around.anchor_index : null); setAllowOneOlderProbe(Boolean(around.anchor_id)); + unreadBelowMessageIdsRef.current = new Set( + normalized + .filter((message) => { + const authorId = message.author_id ?? message.author?.id ?? message.sender?.id; + return authorId !== user?.id && message.id > (lastReadId ?? 0); + }) + .map((message) => message.id) + ); + setNewMessagesBelowCount(Math.max(chatDetails.unread_count ?? unreadBelowMessageIdsRef.current.size, 0)); } else { clearAllPendingTimers(); const response = await apiClient.getChatMessages(chatId, { limit: 50 }); - setMessages(uniqueMessagesById(response.messages || [])); + const normalized = uniqueMessagesById(response.messages || []); + setMessages(normalized); setPendingMessages([]); setHasMoreOlder(Boolean(response.has_more)); setHasMoreNewer(false); setInitialAnchorId(null); setInitialAnchorIndex(null); setAllowOneOlderProbe(false); + unreadBelowMessageIdsRef.current = new Set( + normalized + .filter((message) => { + const authorId = message.author_id ?? message.author?.id ?? message.sender?.id; + return authorId !== user?.id && message.id > (lastReadId ?? 0); + }) + .map((message) => message.id) + ); + setNewMessagesBelowCount(Math.max(chatDetails.unread_count ?? unreadBelowMessageIdsRef.current.size, 0)); } initialScrolledRef.current = false; @@ -888,6 +955,7 @@ export default function MessageDialogPage() { setInitialAnchorId(null); setInitialAnchorIndex(null); setAllowOneOlderProbe(false); + resetNewMessagesBelow(); initialScrolledRef.current = false; anchorScrollAttemptsRef.current = 0; anchorOlderLoadAttemptsRef.current = 0; @@ -898,7 +966,7 @@ export default function MessageDialogPage() { } loadMessages(); - }, [chatId, clearAllPendingTimers, userLoading, user, user?.id]); + }, [chatId, clearAllPendingTimers, resetNewMessagesBelow, userLoading, user, user?.id]); // Скролл к якорю или вниз после загрузки сообщений // Anchor scroll используется при переходе по прямой ссылке на сообщение (например, из уведомления) @@ -917,6 +985,7 @@ export default function MessageDialogPage() { const elRect = el.getBoundingClientRect(); const offset = elRect.top - containerRect.top + viewport.scrollTop - viewport.clientHeight / 2 + elRect.height / 2; viewport.scrollTop = Math.max(0, offset); + syncScrollToBottomState(); initialScrolledRef.current = true; anchorScrollAttemptsRef.current = 0; setInitialAnchorId(null); @@ -933,6 +1002,7 @@ export default function MessageDialogPage() { const elRect = byIndex.getBoundingClientRect(); const offset = elRect.top - containerRect.top + viewport.scrollTop - viewport.clientHeight / 2 + elRect.height / 2; viewport.scrollTop = Math.max(0, offset); + syncScrollToBottomState(); initialScrolledRef.current = true; anchorScrollAttemptsRef.current = 0; setInitialAnchorId(null); @@ -956,6 +1026,7 @@ export default function MessageDialogPage() { // Обычная прокрутка вниз без анимации (автоматический скролл при первой загрузке чата) component.scrollToBottom('auto'); + syncScrollToBottomState(); initialScrolledRef.current = true; anchorScrollAttemptsRef.current = 0; anchorOlderLoadAttemptsRef.current = 0; @@ -965,6 +1036,7 @@ export default function MessageDialogPage() { initialAnchorId, initialAnchorIndex, anchorRetryTick, + syncScrollToBottomState, ]); // Удалено: prependAdjustRef useLayoutEffect @@ -1107,8 +1179,7 @@ export default function MessageDialogPage() { } // Показ кнопки scroll-to-bottom когда не внизу - const isAtBottom = isNearBottom(); - setShowScrollToBottom(!isAtBottom); + const isAtBottom = syncScrollToBottomState(); if (isAtBottom && !hasMoreNewer && messages.length > 0) { const latestLoadedMessage = messages[messages.length - 1]; @@ -1118,7 +1189,7 @@ export default function MessageDialogPage() { viewport.addEventListener("scroll", onScroll, { passive: true }); return () => viewport.removeEventListener("scroll", onScroll); - }, [chatId, displayMessages, hasMoreOlder, hasMoreNewer, isNearBottom, loadNewerMessages, loadOlderMessages, loadingOlder, loadingNewer, messages, messagesLoading, scheduleMarkRead]); + }, [chatId, displayMessages, hasMoreOlder, hasMoreNewer, loadNewerMessages, loadOlderMessages, loadingOlder, loadingNewer, messages, messagesLoading, scheduleMarkRead, syncScrollToBottomState]); const handleSend = async () => { const text = messageText.trim(); @@ -1200,10 +1271,17 @@ export default function MessageDialogPage() { fileInputRef.current?.click(); }; + const addAttachedFiles = useCallback((files: File[]) => { + if (editingMessageId || files.length === 0) { + return; + } + + setAttachedFiles((prev) => [...prev, ...files]); + }, [editingMessageId]); + const handleFilesChange = (e: React.ChangeEvent) => { const files = Array.from(e.target.files || []); - if (!files.length) return; - setAttachedFiles((prev) => [...prev, ...files]); + addAttachedFiles(files); }; const removeAttachedFile = (index: number) => { @@ -1526,12 +1604,19 @@ export default function MessageDialogPage() { if (container) { container.scrollTop = container.scrollHeight; } + + resetNewMessagesBelow(); }} - className="inline-flex h-10 w-10 items-center justify-center rounded-full bg-sky-500 text-white shadow-lg transition hover:bg-sky-600 active:scale-95" + className="relative inline-flex h-10 w-10 items-center justify-center rounded-full bg-sky-500 text-white leading-none shadow-sm shadow-sky-200/70 transition hover:bg-sky-600 active:scale-[0.98] focus:outline-none focus:ring-2 focus:ring-sky-100" title="Вернуться к новым сообщениям" - aria-label="Вернуться к новым сообщениям" + aria-label={newMessagesBelowCount > 0 ? `Вернуться к новым сообщениям (${newMessagesBelowCount > 99 ? "99+" : newMessagesBelowCount})` : "Вернуться к новым сообщениям"} > + {newMessagesBelowCount > 0 ? ( + + {newMessagesBelowCount > 99 ? "99+" : newMessagesBelowCount} + + ) : null}
)} @@ -1561,6 +1646,7 @@ export default function MessageDialogPage() { fileInputRef={fileInputRef} messageInputRef={messageInputRef} onPickFiles={handlePickFiles} + onAddFiles={addAttachedFiles} onFilesChange={handleFilesChange} onRemoveFile={removeAttachedFile} onToggleEmojiPicker={() => { diff --git a/frontend/src/components/messages/MessageComposer.tsx b/frontend/src/components/messages/MessageComposer.tsx index 0b52ef17..9872be4d 100644 --- a/frontend/src/components/messages/MessageComposer.tsx +++ b/frontend/src/components/messages/MessageComposer.tsx @@ -26,6 +26,7 @@ type MessageComposerProps = { fileInputRef: React.RefObject; messageInputRef: React.RefObject; onPickFiles: () => void; + onAddFiles: (files: File[]) => void; onFilesChange: (event: React.ChangeEvent) => void; onRemoveFile: (index: number) => void; onToggleEmojiPicker: () => void; @@ -51,6 +52,7 @@ export default function MessageComposer({ fileInputRef, messageInputRef, onPickFiles, + onAddFiles, onFilesChange, onRemoveFile, onToggleEmojiPicker, @@ -65,6 +67,25 @@ export default function MessageComposer({ const [isExpanded, setIsExpanded] = useState(false); const canSend = editingMessageId ? messageText.trim().length > 0 : messageText.trim().length > 0 || attachedFiles.length > 0; + const handlePaste = (event: React.ClipboardEvent) => { + if (editingMessageId) return; + + const clipboardFiles = Array.from(event.clipboardData.files || []); + const clipboardItemFiles = Array.from(event.clipboardData.items || []) + .filter((item) => item.kind === "file") + .map((item) => item.getAsFile()) + .filter((file): file is File => Boolean(file)); + + const uniqueFiles = [...clipboardFiles, ...clipboardItemFiles].filter((file, index, files) => { + const signature = `${file.name}|${file.size}|${file.type}|${file.lastModified}`; + return files.findIndex((candidate) => `${candidate.name}|${candidate.size}|${candidate.type}|${candidate.lastModified}` === signature) === index; + }); + + if (uniqueFiles.length > 0) { + onAddFiles(uniqueFiles); + } + }; + useLayoutEffect(() => { const input = messageInputRef.current; if (!input) return; @@ -145,7 +166,7 @@ export default function MessageComposer({ onChange={onFilesChange} />
{ if (event.key === "Enter" && !event.shiftKey) { event.preventDefault(); From 3d99226296f82c5045501fbe81c9635db6232839 Mon Sep 17 00:00:00 2001 From: lizerk Date: Fri, 3 Apr 2026 14:25:41 +0300 Subject: [PATCH 11/19] Fix chat list pagination in frontend --- frontend/src/app/employees/page.tsx | 3 +- frontend/src/app/messages-chatscope/page.tsx | 6 ++-- frontend/src/app/messages/page.tsx | 3 +- frontend/src/hooks/useApi.ts | 4 +-- frontend/src/hooks/useUserDetailPage.ts | 14 +------- frontend/src/lib/api/messages.ts | 38 ++++++++++++++++---- 6 files changed, 39 insertions(+), 29 deletions(-) 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/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/page.tsx b/frontend/src/app/messages/page.tsx index f10e7665..93ab0132 100644 --- a/frontend/src/app/messages/page.tsx +++ b/frontend/src/app/messages/page.tsx @@ -112,8 +112,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. diff --git a/frontend/src/hooks/useApi.ts b/frontend/src/hooks/useApi.ts index a85ec691..3da47cf8 100644 --- a/frontend/src/hooks/useApi.ts +++ b/frontend/src/hooks/useApi.ts @@ -154,8 +154,8 @@ export function useChats() { useEffect(() => { async function fetchChats() { try { - const data = await apiClient.getChats(); - setChats(data.results || data); + const data = await apiClient.getAllChats(); + setChats(data); } catch (err) { setError(err as Error); } finally { diff --git a/frontend/src/hooks/useUserDetailPage.ts b/frontend/src/hooks/useUserDetailPage.ts index 2de84cb8..996e392b 100644 --- a/frontend/src/hooks/useUserDetailPage.ts +++ b/frontend/src/hooks/useUserDetailPage.ts @@ -19,23 +19,12 @@ import { } from "@/lib/users/userDetailUtils"; import type { Chat, EmployeeAction, User } from "@/types/api"; -type ChatListResponse = Chat[] | { results?: Chat[] }; type ChatWithMemberIds = Chat & { member_ids?: number[] }; const getErrorMessage = (error: unknown, fallback: string): string => ( String((error as Error)?.message || fallback) ); -const getChatResults = (response: ChatListResponse): ChatWithMemberIds[] => { - if (Array.isArray(response)) { - return response as ChatWithMemberIds[]; - } - - return Array.isArray(response.results) - ? (response.results as ChatWithMemberIds[]) - : []; -}; - export function useUserDetailPage(userId: number, currentUser: User | null) { const router = useRouter(); @@ -107,8 +96,7 @@ export function useUserDetailPage(userId: number, currentUser: User | null) { try { setCreatingChat(true); - const chatsResponse = await apiClient.getChats() as ChatListResponse; - const allChats = getChatResults(chatsResponse); + const allChats = await apiClient.getAllChats() as ChatWithMemberIds[]; const existingChat = allChats.find((chat) => { if (chat.type !== "private") return false; diff --git a/frontend/src/lib/api/messages.ts b/frontend/src/lib/api/messages.ts index 4e2d6c7c..868e9f5b 100644 --- a/frontend/src/lib/api/messages.ts +++ b/frontend/src/lib/api/messages.ts @@ -20,13 +20,39 @@ export function createMessagesApi(request: RequestFn, getToken: GetTokenFn) { return result.message || result; } + function buildChatsQuery(params?: { search?: string; page?: number; page_size?: number }) { + const qp = new URLSearchParams(); + if (params?.search) qp.append('search', params.search); + if (params?.page) qp.append('page', params.page.toString()); + if (params?.page_size) qp.append('page_size', params.page_size.toString()); + const qs = qp.toString(); + return `/api/v1/communications/chats/${qs ? '?' + qs : ''}`; + } + return { - getChats: (params?: { search?: string; page?: number }) => { - const qp = new URLSearchParams(); - if (params?.search) qp.append('search', params.search); - if (params?.page) qp.append('page', params.page.toString()); - const qs = qp.toString(); - return request(`/api/v1/communications/chats/${qs ? '?' + qs : ''}`); + getChats: (params?: { search?: string; page?: number; page_size?: number }) => request(buildChatsQuery(params)), + getAllChats: async (params?: { search?: string; page_size?: number }) => { + const pageSize = params?.page_size ?? 200; + const chats: any[] = []; + let page = 1; + + for (;;) { + const response = await request(buildChatsQuery({ + search: params?.search, + page, + page_size: pageSize, + })) as { results?: any[]; next?: string | null } | any[]; + const results = Array.isArray(response) ? response : (response.results || []); + chats.push(...results); + + if (Array.isArray(response) || !response.next) { + break; + } + + page += 1; + } + + return chats; }, getChat: (chatId: number) => request(`/api/v1/communications/chats/${chatId}/`), createChat: (data: { type: string; participants?: number[]; name?: string; description?: string; department?: number; include_all_employees?: boolean; avatar?: File }) => { From 068e956f7cc55aaf02ae8986a84a54bc4b266878 Mon Sep 17 00:00:00 2001 From: lizerk Date: Fri, 3 Apr 2026 14:32:31 +0300 Subject: [PATCH 12/19] Fix chat floating date badge behavior --- frontend/src/app/messages/[chatId]/page.tsx | 91 +++++++++++++-------- 1 file changed, 56 insertions(+), 35 deletions(-) diff --git a/frontend/src/app/messages/[chatId]/page.tsx b/frontend/src/app/messages/[chatId]/page.tsx index ed1a0711..fb5739e6 100644 --- a/frontend/src/app/messages/[chatId]/page.tsx +++ b/frontend/src/app/messages/[chatId]/page.tsx @@ -18,7 +18,7 @@ import { useChatFallbackSync } from "@/hooks/useChatFallbackSync"; import { useSilentChatReloadGuard } from "@/hooks/useSilentChatReloadGuard"; import { useWebSocket } from "@/hooks/useWebSocket"; import { getMessageTimestamp, mergeDisplayMessages, uniqueMessagesById } from "@/lib/messages/chatUtils"; -import { formatDayDivider, getMessagePreviewText, getReplyToId } from "@/lib/messages/messageUtils"; +import { formatDayDivider, getMessageDate, getMessagePreviewText, getReplyToId } from "@/lib/messages/messageUtils"; import wsManager from "@/lib/websocketManager"; import ScrollableMessageList, { ScrollableMessageListInner } from "@/components/ScrollableMessageList"; @@ -57,6 +57,15 @@ function uniqueEmoji(items: string[]): string[] { return out; } +function getMessageDayKey(message: Message): string | null { + const date = getMessageDate(message); + if (!date) { + return null; + } + + return `${date.getFullYear()}-${date.getMonth()}-${date.getDate()}`; +} + export default function MessageDialogPage() { const params = useParams<{ chatId: string }>(); const chatId = Number(params.chatId); @@ -1148,20 +1157,20 @@ export default function MessageDialogPage() { loadNewerMessages(); } - // Обновление плавающей даты const messageEls = viewport.querySelectorAll('[data-message-date]'); - let topDate: string | null = null; const viewportTop = viewport.getBoundingClientRect().top; + let topDate: string | null = null; - for (let i = 0; i < messageEls.length; i++) { + for (let i = 0; i < messageEls.length; i += 1) { const el = messageEls[i]; const rect = el.getBoundingClientRect(); - if (rect.bottom > viewportTop) { + + if (rect.bottom > viewportTop + 8) { const isoDate = el.getAttribute('data-message-date'); if (isoDate) { - const d = new Date(isoDate); - if (!Number.isNaN(d.getTime())) { - topDate = formatDayDivider(d); + const date = new Date(isoDate); + if (!Number.isNaN(date.getTime())) { + topDate = formatDayDivider(date); } } break; @@ -1172,10 +1181,13 @@ export default function MessageDialogPage() { setFloatingDate(topDate); setShowFloatingDate(true); - if (floatingDateTimeoutRef.current) clearTimeout(floatingDateTimeoutRef.current); + if (floatingDateTimeoutRef.current) { + clearTimeout(floatingDateTimeoutRef.current); + } + floatingDateTimeoutRef.current = setTimeout(() => { setShowFloatingDate(false); - }, 1500); + }, 1100); } // Показ кнопки scroll-to-bottom когда не внизу @@ -1538,16 +1550,13 @@ export default function MessageDialogPage() { />
- {/* Плавающая дата */} - {floatingDate && ( -
+ {floatingDate ? ( +
{floatingDate}
- )} + ) : null} Подгружаем старые сообщения...

) : null} - {displayMessages.map((message) => { + {displayMessages.map((message, index) => { const replyToId = getReplyToId(message); const repliedMessage = replyToId ? messagesById.get(replyToId) : null; + const currentDayKey = getMessageDayKey(message); + const previousDayKey = index > 0 ? getMessageDayKey(displayMessages[index - 1]) : null; + const currentMessageDate = getMessageDate(message); + const showDayDivider = Boolean(currentMessageDate && currentDayKey !== previousDayKey); return ( - + + {showDayDivider && currentMessageDate ? ( +
+ + {formatDayDivider(currentMessageDate)} + +
+ ) : null} + +
); })}
From 9d3d8bdd57b363016c13287edcacc09b04490a63 Mon Sep 17 00:00:00 2001 From: lizerk Date: Fri, 3 Apr 2026 15:14:01 +0300 Subject: [PATCH 13/19] Fix chat history return button behavior --- frontend/src/app/messages/[chatId]/page.tsx | 274 ++++++++++++++++-- .../src/components/ScrollableMessageList.tsx | 66 +++-- 2 files changed, 301 insertions(+), 39 deletions(-) diff --git a/frontend/src/app/messages/[chatId]/page.tsx b/frontend/src/app/messages/[chatId]/page.tsx index fb5739e6..bfb8f767 100644 --- a/frontend/src/app/messages/[chatId]/page.tsx +++ b/frontend/src/app/messages/[chatId]/page.tsx @@ -2,7 +2,7 @@ import React, { useEffect, useLayoutEffect, useMemo, useRef, useState, useCallback } from "react"; import { useParams } from "next/navigation"; -import { MessageCircle, ChevronDown } from "lucide-react"; +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"; @@ -66,6 +66,24 @@ function getMessageDayKey(message: Message): string | null { return `${date.getFullYear()}-${date.getMonth()}-${date.getDate()}`; } +function formatDateInputValue(date: Date): string { + const year = date.getFullYear(); + const month = `${date.getMonth() + 1}`.padStart(2, "0"); + const day = `${date.getDate()}`.padStart(2, "0"); + + return `${year}-${month}-${day}`; +} + +function shiftDateInputValue(value: string, deltaDays: number): string { + const baseDate = value ? new Date(`${value}T00:00:00`) : new Date(); + if (Number.isNaN(baseDate.getTime())) { + return formatDateInputValue(new Date()); + } + + baseDate.setDate(baseDate.getDate() + deltaDays); + return formatDateInputValue(baseDate); +} + export default function MessageDialogPage() { const params = useParams<{ chatId: string }>(); const chatId = Number(params.chatId); @@ -93,7 +111,12 @@ export default function MessageDialogPage() { const [useOriginalImage, setUseOriginalImage] = useState>({}); const [mediaPreview, setMediaPreview] = useState(null); const [floatingDate, setFloatingDate] = useState(null); + const [floatingDateValue, setFloatingDateValue] = useState(null); const [showFloatingDate, setShowFloatingDate] = useState(false); + const [isDateNavigatorOpen, setIsDateNavigatorOpen] = useState(false); + const [selectedHistoryDate, setSelectedHistoryDate] = useState(""); + const [jumpingToDate, setJumpingToDate] = useState(false); + const [historyNavigationMode, setHistoryNavigationMode] = useState(false); const [showScrollToBottom, setShowScrollToBottom] = useState(false); const [newMessagesBelowCount, setNewMessagesBelowCount] = useState(0); const floatingDateTimeoutRef = useRef(null); @@ -148,6 +171,12 @@ export default function MessageDialogPage() { pendingMessagesRef.current = pendingMessages; }, [pendingMessages]); + useEffect(() => { + if (floatingDateValue && !isDateNavigatorOpen) { + setSelectedHistoryDate(floatingDateValue); + } + }, [floatingDateValue, isDateNavigatorOpen]); + useEffect(() => { lastConfirmedMarkReadIdRef.current = Math.max( lastConfirmedMarkReadIdRef.current, @@ -166,6 +195,7 @@ export default function MessageDialogPage() { lastConfirmedMarkReadIdRef.current = chat?.last_read_message_id ?? 0; unreadBelowMessageIdsRef.current.clear(); setNewMessagesBelowCount(0); + setHistoryNavigationMode(false); }, [chatId, chat?.last_read_message_id]); // Определяем текущий membership и права на отправку сообщений @@ -495,14 +525,19 @@ export default function MessageDialogPage() { const syncScrollToBottomState = useCallback(() => { const isAtBottom = isNearBottom(); - setShowScrollToBottom(!isAtBottom); + const hasBufferedNewer = hasMoreNewerRef.current || unreadBelowMessageIdsRef.current.size > 0; - if (isAtBottom) { + if (isAtBottom && !hasMoreNewerRef.current) { + setShowScrollToBottom(false); resetNewMessagesBelow(); + setHistoryNavigationMode(false); + return true; } + setShowScrollToBottom(!isAtBottom || hasBufferedNewer || historyNavigationMode); + return isAtBottom; - }, [isNearBottom, resetNewMessagesBelow]); + }, [historyNavigationMode, isNearBottom, resetNewMessagesBelow]); // Автопрокрутка вниз const scrollToBottom = useCallback((smooth = false) => { @@ -1094,7 +1129,7 @@ export default function MessageDialogPage() { } const newestMessage = messages[messages.length - 1]; - const shouldMarkRead = isNearBottom(); + const shouldMarkRead = !historyNavigationMode && isNearBottom(); try { setLoadingNewer(true); @@ -1126,7 +1161,7 @@ export default function MessageDialogPage() { } finally { setLoadingNewer(false); } - }, [chatId, hasMoreNewer, isNearBottom, loadingNewer, messages, messagesLoading, removePendingMessageByServerMessage, scheduleMarkRead]); + }, [chatId, hasMoreNewer, historyNavigationMode, isNearBottom, loadingNewer, messages, messagesLoading, removePendingMessageByServerMessage, scheduleMarkRead]); useChatFallbackSync({ chatId: chatId || null, @@ -1151,15 +1186,16 @@ export default function MessageDialogPage() { loadOlderMessages(); } - // Загрузка новых сообщений при приближении к низу - const distanceFromBottom = viewport.scrollHeight - viewport.scrollTop - viewport.clientHeight; - if (!loadingNewer && !messagesLoading && hasMoreNewer && distanceFromBottom <= 120) { + // Загрузка новых сообщений при приближении к низу (щедрый порог для предзагрузки) + const distFromBottom = viewport.scrollHeight - viewport.scrollTop - viewport.clientHeight; + if (!loadingNewer && !messagesLoading && hasMoreNewer && distFromBottom <= 300) { loadNewerMessages(); } const messageEls = viewport.querySelectorAll('[data-message-date]'); const viewportTop = viewport.getBoundingClientRect().top; let topDate: string | null = null; + let topDateValue: string | null = null; for (let i = 0; i < messageEls.length; i += 1) { const el = messageEls[i]; @@ -1171,6 +1207,7 @@ export default function MessageDialogPage() { const date = new Date(isoDate); if (!Number.isNaN(date.getTime())) { topDate = formatDayDivider(date); + topDateValue = formatDateInputValue(date); } } break; @@ -1179,6 +1216,7 @@ export default function MessageDialogPage() { if (topDate) { setFloatingDate(topDate); + setFloatingDateValue(topDateValue); setShowFloatingDate(true); if (floatingDateTimeoutRef.current) { @@ -1409,6 +1447,135 @@ export default function MessageDialogPage() { setUseOriginalImage((prev) => ({ ...prev, [attachmentId]: true })); }, []); + const openDateNavigator = useCallback((dateValue?: string | null) => { + setSelectedHistoryDate(dateValue || floatingDateValue || formatDateInputValue(new Date())); + setShowFloatingDate(true); + setIsDateNavigatorOpen(true); + }, [floatingDateValue]); + + const handleJumpToDate = useCallback(async (dateValue: string) => { + if (!chatId || Number.isNaN(chatId) || !dateValue || jumpingToDate) { + return; + } + + const dayStart = new Date(`${dateValue}T00:00:00`); + if (Number.isNaN(dayStart.getTime())) { + return; + } + + try { + setJumpingToDate(true); + setHistoryNavigationMode(true); + setMessagesLoading(true); + setIsDateNavigatorOpen(false); + + const [olderResponse, newerResponse] = await Promise.all([ + apiClient.getChatMessages(chatId, { + limit: 20, + before: dayStart.toISOString(), + }), + apiClient.getChatMessages(chatId, { + limit: 20, + after: dayStart.toISOString(), + mark_read: false, + }), + ]); + + const olderMessages = olderResponse.messages || []; + const newerMessages = newerResponse.messages || []; + const combinedMessages = uniqueMessagesById([...olderMessages, ...newerMessages]); + const anchorMessage = newerMessages[0] || olderMessages[olderMessages.length - 1] || combinedMessages[0] || null; + + if (!anchorMessage) { + return; + } + + setMessages(combinedMessages); + setHasMoreOlder(Boolean(olderResponse.has_more)); + setHasMoreNewer(Boolean(newerResponse.has_more)); + setInitialAnchorId(anchorMessage.id); + setInitialAnchorIndex(combinedMessages.findIndex((message) => message.id === anchorMessage.id)); + setAllowOneOlderProbe(Boolean(newerMessages[0])); + + const anchorDate = getMessageDate(anchorMessage); + if (anchorDate) { + setFloatingDate(formatDayDivider(anchorDate)); + setFloatingDateValue(formatDateInputValue(anchorDate)); + setShowFloatingDate(true); + } + + initialScrolledRef.current = false; + anchorScrollAttemptsRef.current = 0; + anchorOlderLoadAttemptsRef.current = 0; + setAnchorRetryTick(0); + resetNewMessagesBelow(); + } catch (jumpError) { + console.error("Ошибка перехода к дате:", jumpError); + } finally { + setMessagesLoading(false); + setJumpingToDate(false); + } + }, [chatId, jumpingToDate, resetNewMessagesBelow]); + + const handleReturnToNewMessages = useCallback(async () => { + if (!chatId || Number.isNaN(chatId)) { + return; + } + + const component = messagesViewportRef.current; + const container = component?.containerRef?.current; + const lastReadMessageId = Math.max(chat?.last_read_message_id ?? 0, lastConfirmedMarkReadIdRef.current); + const shouldRestoreUnreadBoundary = (hasMoreNewer || historyNavigationMode) && lastReadMessageId > 0; + + if (!shouldRestoreUnreadBoundary) { + setHistoryNavigationMode(false); + if (container) { + container.scrollTop = container.scrollHeight; + } + resetNewMessagesBelow(); + return; + } + + try { + setMessagesLoading(true); + + const around = await apiClient.getChatMessagesAround(chatId, { + limit: 30, + around_id: lastReadMessageId, + }); + + const normalized = uniqueMessagesById(around.messages || []); + if (normalized.length === 0) { + if (container) { + container.scrollTop = container.scrollHeight; + } + setHistoryNavigationMode(false); + resetNewMessagesBelow(); + return; + } + + setMessages(normalized); + setHasMoreOlder( + typeof around.has_more_before === "boolean" ? around.has_more_before : normalized.length >= 50 + ); + setHasMoreNewer(Boolean(around.has_more_after)); + setInitialAnchorId(around.anchor_id ?? lastReadMessageId); + setInitialAnchorIndex(typeof around.anchor_index === "number" ? around.anchor_index : null); + setAllowOneOlderProbe(Boolean(around.anchor_id ?? lastReadMessageId)); + setHistoryNavigationMode(Boolean(around.has_more_after)); + setShowScrollToBottom(true); + + initialScrolledRef.current = false; + anchorScrollAttemptsRef.current = 0; + anchorOlderLoadAttemptsRef.current = 0; + setAnchorRetryTick(0); + } catch (error) { + console.error("Ошибка возврата к непрочитанным сообщениям:", error); + } finally { + setMessagesLoading(false); + } + }, [chat?.last_read_message_id, chatId, hasMoreNewer, historyNavigationMode, resetNewMessagesBelow]); + const handleTogglePin = async () => { if (!chatId) return; @@ -1452,11 +1619,14 @@ export default function MessageDialogPage() { if (target.closest("[data-reaction-picker='true']")) return; if (target.closest(".reaction-picker-modal")) return; if (target.closest("[data-composer-emoji='true']")) return; + if (target.closest("[data-date-navigator='true']")) return; + if (target.closest("[data-date-trigger='true']")) return; setExpandedReplyActionForId(null); setActionsMenuAnchor(null); setReactionPickerForMessageId(null); setShowComposerEmojiPicker(false); + setIsDateNavigatorOpen(false); }; window.addEventListener("mousedown", onWindowClick); @@ -1474,6 +1644,7 @@ export default function MessageDialogPage() { setActionsMenuAnchor(null); setReactionPickerForMessageId(null); setShowComposerEmojiPicker(false); + setIsDateNavigatorOpen(false); } }; window.addEventListener("keydown", onKey); @@ -1551,17 +1722,76 @@ export default function MessageDialogPage() {
{floatingDate ? ( -
- +
+ +
+ ) : null} + + {isDateNavigatorOpen ? ( +
+

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

+
+ + setSelectedHistoryDate(event.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} @@ -1586,9 +1816,14 @@ export default function MessageDialogPage() { {showDayDivider && currentMessageDate ? (
- +
) : null} { - const container = messagesViewportRef.current?.containerRef?.current; - if (container) { - container.scrollTop = container.scrollHeight; - } - - resetNewMessagesBelow(); + void handleReturnToNewMessages(); }} className="relative inline-flex h-10 w-10 items-center justify-center rounded-full bg-sky-500 text-white leading-none shadow-sm shadow-sky-200/70 transition hover:bg-sky-600 active:scale-[0.98] focus:outline-none focus:ring-2 focus:ring-sky-100" title="Вернуться к новым сообщениям" diff --git a/frontend/src/components/ScrollableMessageList.tsx b/frontend/src/components/ScrollableMessageList.tsx index 88249edf..fde3c025 100644 --- a/frontend/src/components/ScrollableMessageList.tsx +++ b/frontend/src/components/ScrollableMessageList.tsx @@ -27,6 +27,8 @@ interface Snapshot { clientHeight: number; scrollHeight: number; diff: number; + anchorMessageId: string | null; + anchorOffsetTop: number | null; } export class ScrollableMessageListInner extends Component { @@ -47,6 +49,29 @@ export class ScrollableMessageListInner extends Component('[data-message-id]'); + + for (const element of messageElements) { + const messageId = element.dataset.messageId; + if (!messageId) { + continue; + } + + const rect = element.getBoundingClientRect(); + if (rect.bottom > viewportTop) { + return { + messageId, + offsetTop: rect.top - containerRect.top, + }; + } + } + + return null; + } + componentDidMount() { // Начальный скролл вниз при монтировании if (this.props.autoScrollToBottomOnMount) { @@ -115,11 +140,15 @@ export class ScrollableMessageListInner extends Component= snapshot.scrollHeight - 5 && container.scrollHeight > snapshot.scrollHeight) { - // Прокручиваем ровно на высоту добавленного контента - // Чтобы первое "старое" сообщение осталось видимым в той же позиции - const addedHeight = container.scrollHeight - snapshot.scrollHeight; - container.scrollTop = addedHeight; - } else { - // Стандартное восстановление позиции для всех остальных случаев - // Сохраняет расстояние от текущей позиции до конца списка - container.scrollTop = - container.scrollHeight - - snapshot.diff + - (this.lastClientHeight - container.clientHeight); + + if (snapshot.anchorMessageId && snapshot.anchorOffsetTop !== null) { + const anchorElement = container.querySelector(`[data-message-id="${snapshot.anchorMessageId}"]`); + + if (anchorElement) { + const containerRect = container.getBoundingClientRect(); + const currentOffsetTop = anchorElement.getBoundingClientRect().top - containerRect.top; + const offsetDiff = currentOffsetTop - snapshot.anchorOffsetTop; + + if (Math.abs(offsetDiff) > 0.5) { + container.scrollTop += offsetDiff; + } + } } } } @@ -191,7 +217,7 @@ export class ScrollableMessageListInner extends Component { + handleContentMutation = (mutations: MutationRecord[] = []) => { const container = this.containerRef.current; if (!container) return; @@ -200,6 +226,12 @@ export class ScrollableMessageListInner extends Component mutation.type === 'childList'); + + if (hasChildListMutation) { + this.lastScrollHeight = currentScrollHeight; + return; + } // ВАЖНО: Не корректируем если scrollTop близко к 0 (это prepend operation) // В этом случае коррекцию делает componentDidUpdate с правильной логикой From e80412918d207363be82e654991d940d0aac7169 Mon Sep 17 00:00:00 2001 From: lizerk Date: Fri, 3 Apr 2026 17:05:14 +0300 Subject: [PATCH 14/19] Refactor chat frontend and optimize chat queries --- backend/communications/api/serializers.py | 29 +- backend/communications/api/viewsets.py | 11 +- docs/reports/CHAT_REFACTORING_ANALYSIS.md | 150 ++ frontend/src/app/messages/[chatId]/page.tsx | 1939 ++++--------------- frontend/src/hooks/useChatMessages.ts | 576 ++++++ frontend/src/hooks/useChatScroll.ts | 194 ++ frontend/src/hooks/useMarkRead.ts | 124 ++ 7 files changed, 1496 insertions(+), 1527 deletions(-) create mode 100644 docs/reports/CHAT_REFACTORING_ANALYSIS.md create mode 100644 frontend/src/hooks/useChatMessages.ts create mode 100644 frontend/src/hooks/useChatScroll.ts create mode 100644 frontend/src/hooks/useMarkRead.ts 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 736a82d2..243eb75f 100644 --- a/backend/communications/api/viewsets.py +++ b/backend/communications/api/viewsets.py @@ -116,7 +116,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 +138,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() # Исключаем чаты-комментарии из общего списка 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/src/app/messages/[chatId]/page.tsx b/frontend/src/app/messages/[chatId]/page.tsx index bfb8f767..36791bcc 100644 --- a/frontend/src/app/messages/[chatId]/page.tsx +++ b/frontend/src/app/messages/[chatId]/page.tsx @@ -12,77 +12,52 @@ 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 { 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 { getMessageTimestamp, mergeDisplayMessages, uniqueMessagesById } from "@/lib/messages/chatUtils"; +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 PENDING_MESSAGE_DELAY_MS = 8000; -const MARK_READ_DEBOUNCE_MS = 300; 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 date = getMessageDate(message); - if (!date) { - return null; - } - - return `${date.getFullYear()}-${date.getMonth()}-${date.getDate()}`; + const d = getMessageDate(message); + return d ? `${d.getFullYear()}-${d.getMonth()}-${d.getDate()}` : null; } function formatDateInputValue(date: Date): string { - const year = date.getFullYear(); - const month = `${date.getMonth() + 1}`.padStart(2, "0"); - const day = `${date.getDate()}`.padStart(2, "0"); - - return `${year}-${month}-${day}`; + return `${date.getFullYear()}-${`${date.getMonth()+1}`.padStart(2,"0")}-${`${date.getDate()}`.padStart(2,"0")}`; } -function shiftDateInputValue(value: string, deltaDays: number): string { - const baseDate = value ? new Date(`${value}T00:00:00`) : new Date(); - if (Number.isNaN(baseDate.getTime())) { - return formatDateInputValue(new Date()); - } - - baseDate.setDate(baseDate.getDate() + deltaDays); - return formatDateInputValue(baseDate); -} +/* ─── component ─── */ export default function MessageDialogPage() { const params = useParams<{ chatId: string }>(); @@ -90,12 +65,63 @@ export default function MessageDialogPage() { const { user, loading: userLoading } = useUser(); const { handleReconnectExhausted, resetReloadGuard } = useSilentChatReloadGuard(chatId || null); - const [chat, setChat] = useState(null); - const [messages, setMessages] = useState([]); - const [pendingMessages, setPendingMessages] = 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); + + /* ────────────────────────────────────────────────────── + * 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); @@ -110,741 +136,71 @@ export default function MessageDialogPage() { const [brokenMedia, setBrokenMedia] = useState>({}); const [useOriginalImage, setUseOriginalImage] = useState>({}); const [mediaPreview, setMediaPreview] = useState(null); - const [floatingDate, setFloatingDate] = useState(null); - const [floatingDateValue, setFloatingDateValue] = useState(null); - const [showFloatingDate, setShowFloatingDate] = useState(false); - const [isDateNavigatorOpen, setIsDateNavigatorOpen] = useState(false); - const [selectedHistoryDate, setSelectedHistoryDate] = useState(""); - const [jumpingToDate, setJumpingToDate] = useState(false); - const [historyNavigationMode, setHistoryNavigationMode] = useState(false); - const [showScrollToBottom, setShowScrollToBottom] = useState(false); - const [newMessagesBelowCount, setNewMessagesBelowCount] = useState(0); - 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 pendingTimersRef = useRef>(new Map()); - const pendingMessagesRef = useRef([]); - const localMessageSequenceRef = useRef(0); - const markReadTimerRef = useRef(null); - const markReadInFlightRef = useRef(false); - const queuedMarkReadIdRef = useRef(null); - const lastConfirmedMarkReadIdRef = useRef(0); - const unreadBelowMessageIdsRef = useRef>(new Set()); - - // Локальное состояние для настроек чата const [isPinned, setIsPinned] = useState(false); const [notificationsEnabled, setNotificationsEnabled] = useState(true); - - // Синхронизируем ref с state + const [jumpingToDate, setJumpingToDate] = useState(false); + + // 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 displayMessages = useMemo(() => mergeDisplayMessages(messages, pendingMessages), [messages, pendingMessages]); - const messagesById = useMemo(() => new Map(displayMessages.map((m) => [m.id, m])), [displayMessages]); + }, [cm.chat]); - useEffect(() => { - pendingMessagesRef.current = pendingMessages; - }, [pendingMessages]); - - useEffect(() => { - if (floatingDateValue && !isDateNavigatorOpen) { - setSelectedHistoryDate(floatingDateValue); - } - }, [floatingDateValue, isDateNavigatorOpen]); - - useEffect(() => { - lastConfirmedMarkReadIdRef.current = Math.max( - lastConfirmedMarkReadIdRef.current, - chat?.last_read_message_id ?? 0, - ); - }, [chat?.last_read_message_id]); - - useEffect(() => { - if (markReadTimerRef.current) { - clearTimeout(markReadTimerRef.current); - markReadTimerRef.current = null; - } - - markReadInFlightRef.current = false; - queuedMarkReadIdRef.current = null; - lastConfirmedMarkReadIdRef.current = chat?.last_read_message_id ?? 0; - unreadBelowMessageIdsRef.current.clear(); - setNewMessagesBelowCount(0); - setHistoryNavigationMode(false); - }, [chatId, chat?.last_read_message_id]); - - // Определяем текущий membership и права на отправку сообщений + /* ── 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] - ); + const selectedActionRecentReactions = useMemo(() => uniqueEmoji(recentReactions).slice(0, MAX_RECENT_REACTIONS), [recentReactions]); const selectedActionReaders = selectedActionMessage?.read_by || []; const sendingComposer = Boolean(editingMessageId && sending); - const clearPendingTimer = useCallback((localId?: string | null) => { - if (!localId) return; - - const timer = pendingTimersRef.current.get(localId); - if (timer) { - clearTimeout(timer); - pendingTimersRef.current.delete(localId); - } - }, []); - - const markPendingAsDelayed = useCallback((localId: string) => { - setPendingMessages((prev) => - prev.map((message) => - message.local_id === localId && message.send_state === "pending" - ? { ...message, send_state: "delayed" } - : message - ) - ); - }, []); - - const schedulePendingTimer = useCallback((localId: string) => { - clearPendingTimer(localId); - - const timer = setTimeout(() => { - pendingTimersRef.current.delete(localId); - markPendingAsDelayed(localId); - }, PENDING_MESSAGE_DELAY_MS); - - pendingTimersRef.current.set(localId, timer); - }, [clearPendingTimer, markPendingAsDelayed]); - - const removePendingMessage = useCallback((localId?: string | null) => { - if (!localId) return; - - clearPendingTimer(localId); - setPendingMessages((prev) => prev.filter((message) => message.local_id !== localId)); - }, [clearPendingTimer]); - - const clearAllPendingTimers = useCallback(() => { - pendingTimersRef.current.forEach((timer) => clearTimeout(timer)); - pendingTimersRef.current.clear(); - }, []); - - const resetNewMessagesBelow = useCallback(() => { - unreadBelowMessageIdsRef.current.clear(); - setNewMessagesBelowCount(0); - }, []); - - const registerNewMessagesBelow = useCallback((incomingMessages: Message[]) => { - if (!user?.id || incomingMessages.length === 0) { - return; - } - - let addedCount = 0; - - incomingMessages.forEach((message) => { - const authorId = message.author_id ?? message.author?.id ?? message.sender?.id; - if (authorId === user.id || message.id <= 0 || unreadBelowMessageIdsRef.current.has(message.id)) { - return; - } - - unreadBelowMessageIdsRef.current.add(message.id); - addedCount += 1; - }); - - if (addedCount > 0) { - setNewMessagesBelowCount((prev) => prev + addedCount); - } - }, [user?.id]); - - const flushMarkRead = useCallback(async () => { - if (!chatId || Number.isNaN(chatId) || markReadInFlightRef.current) { - return; - } - - const targetMessageId = queuedMarkReadIdRef.current; - if (typeof targetMessageId !== "number") { - return; - } - - if (targetMessageId <= lastConfirmedMarkReadIdRef.current) { - queuedMarkReadIdRef.current = null; - return; - } - - queuedMarkReadIdRef.current = null; - markReadInFlightRef.current = true; - - try { - await apiClient.markChatAsRead(chatId, targetMessageId); - lastConfirmedMarkReadIdRef.current = Math.max( - lastConfirmedMarkReadIdRef.current, - targetMessageId, - ); - setChat((prev) => - prev - ? { - ...prev, - last_read_message_id: Math.max(prev.last_read_message_id ?? 0, targetMessageId), - } - : prev - ); - } catch (error) { - console.error("Ошибка отложенной отметки прочтения:", error); - queuedMarkReadIdRef.current = Math.max( - queuedMarkReadIdRef.current ?? 0, - targetMessageId, - ); - } finally { - markReadInFlightRef.current = false; - - if ( - queuedMarkReadIdRef.current !== null && - queuedMarkReadIdRef.current > lastConfirmedMarkReadIdRef.current && - !markReadTimerRef.current - ) { - markReadTimerRef.current = setTimeout(() => { - markReadTimerRef.current = null; - void flushMarkRead(); - }, MARK_READ_DEBOUNCE_MS); - } - } - }, [chatId]); - - const scheduleMarkRead = useCallback((messageId?: number | null) => { - if (!chatId || typeof messageId !== "number") { - return; - } - - const nextTarget = Math.max(queuedMarkReadIdRef.current ?? 0, messageId); - if (nextTarget <= lastConfirmedMarkReadIdRef.current) { - return; - } - - queuedMarkReadIdRef.current = nextTarget; - - if (markReadInFlightRef.current) { - return; - } - - if (markReadTimerRef.current) { - clearTimeout(markReadTimerRef.current); - } - - markReadTimerRef.current = setTimeout(() => { - markReadTimerRef.current = null; - void flushMarkRead(); - }, MARK_READ_DEBOUNCE_MS); - }, [chatId, flushMarkRead]); - - const getCurrentUserDisplayName = useCallback(() => { - if (!user) return "Вы"; - - const fullName = [user.last_name, user.first_name, user.patronymic].filter(Boolean).join(" ").trim(); - return fullName || user.email || "Вы"; - }, [user]); - - const guessAttachmentType = useCallback((file: File): string => { - const mime = (file.type || "").toLowerCase(); - if (mime.startsWith("image/")) return "image"; - if (mime.startsWith("video/")) return "video"; - if (mime.startsWith("audio/")) return "audio"; - return "file"; - }, []); - - const getAttachmentSignature = useCallback((message: Message) => { - return (message.attachments || []) - .map((attachment) => `${attachment.file_name}|${attachment.file_size ?? 0}|${attachment.mime_type ?? ""}`) - .sort() - .join("||"); - }, []); - - const findMatchingPendingLocalId = useCallback((confirmedMessage: Message) => { - if (!user?.id || confirmedMessage.author_id !== user.id) { - return null; - } - - const confirmedReplyToId = getReplyToId(confirmedMessage) ?? null; - const confirmedAttachmentSignature = getAttachmentSignature(confirmedMessage); - const confirmedTimestamp = getMessageTimestamp(confirmedMessage); - - const candidates = pendingMessagesRef.current - .filter((pending) => pending.author_id === user.id) - .filter((pending) => pending.content === confirmedMessage.content) - .filter((pending) => (getReplyToId(pending) ?? null) === confirmedReplyToId) - .filter((pending) => getAttachmentSignature(pending) === confirmedAttachmentSignature) - .map((pending) => ({ - localId: pending.local_id ?? null, - diff: Math.abs(getMessageTimestamp(pending) - confirmedTimestamp), - })) - .filter((candidate) => Boolean(candidate.localId)) - .sort((left, right) => left.diff - right.diff); - - const matchedCandidate = candidates[0]; - if (!matchedCandidate) { - return null; - } - - if (confirmedTimestamp > 0 && matchedCandidate.diff > 10 * 60 * 1000) { - return null; - } - - return matchedCandidate.localId; - }, [getAttachmentSignature, user?.id]); - - const removePendingMessageByServerMessage = useCallback((confirmedMessage: Message) => { - const localId = findMatchingPendingLocalId(confirmedMessage); - if (!localId) { - return; - } - - removePendingMessage(localId); - }, [findMatchingPendingLocalId, removePendingMessage]); - - const buildOptimisticMessage = useCallback((text: string, files: File[], localId: string): Message => { - localMessageSequenceRef.current += 1; - const now = Date.now() + localMessageSequenceRef.current; - - return { - id: -now, - chat: chatId, - local_id: localId, - author_id: user?.id, - author_name: getCurrentUserDisplayName(), - avatar: user?.avatar, - content: text, - is_read: false, - send_state: "pending", - is_optimistic: true, - created_at: new Date(now).toISOString(), - created_ts: now, - has_attachments: files.length > 0, - attachments: files.map((file, index) => ({ - id: -(now * 100 + index), - file_name: file.name, - file_type: guessAttachmentType(file), - file_url: "", - file_size: file.size, - mime_type: file.type, - is_local: true, - })), - reply_to_id: replyTo?.id, - }; - }, [chatId, getCurrentUserDisplayName, guessAttachmentType, replyTo?.id, user?.avatar, user?.id]); - - const resetComposerState = useCallback(() => { - setEditingMessageId(null); - setMessageText(""); - setReplyTo(null); - setExpandedReplyActionForId(null); - setAttachedFiles([]); - if (fileInputRef.current) { - fileInputRef.current.value = ""; - } - }, []); - - // WebSocket для real-time обновлений - const { isConnected, sendTyping, handlers } = useWebSocket({ - chatId: chatId || null, - autoConnect: true, - onReconnectExhausted: handleReconnectExhausted, - }); - + /* ── 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 syncScrollToBottomState = useCallback(() => { - const isAtBottom = isNearBottom(); - const hasBufferedNewer = hasMoreNewerRef.current || unreadBelowMessageIdsRef.current.size > 0; - - if (isAtBottom && !hasMoreNewerRef.current) { - setShowScrollToBottom(false); - resetNewMessagesBelow(); - setHistoryNavigationMode(false); - return true; - } - - setShowScrollToBottom(!isAtBottom || hasBufferedNewer || historyNavigationMode); - - return isAtBottom; - }, [historyNavigationMode, isNearBottom, resetNewMessagesBelow]); - - // Автопрокрутка вниз - 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: false, - }); - - const newerMessages = response.messages || []; - if (newerMessages.length > 0) { - newerMessages.forEach(removePendingMessageByServerMessage); - setMessages((prev) => uniqueMessagesById([...prev, ...newerMessages])); - - if (shouldMarkRead) { - const lastLoadedMessage = newerMessages[newerMessages.length - 1]; - scheduleMarkRead(lastLoadedMessage.id); - } - - 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, removePendingMessageByServerMessage, scheduleMarkRead, 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(); - removePendingMessageByServerMessage(newMsg); - - if (!isMyMessage && !wasNearBottom) { - registerNewMessagesBelow([newMsg]); - setShowScrollToBottom(true); - } - - 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) { - scheduleMarkRead(newMsg.id); - } - - // Автопрокрутка вниз если: - // 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; - const readerUserId = typeof data.reader_user_id === 'number' ? data.reader_user_id : null; - - if (readerUserId !== null) { - setMessages((prev) => - prev.map((message) => { - const authorId = message.author_id ?? message.author?.id ?? message.sender?.id; - if (message.id > lastReadMessageId || authorId === readerUserId || message.is_read) { - return message; - } - - return { - ...message, - is_read: true, - }; - }) - ); - } - - if (readerUserId === null || readerUserId === user?.id) { - lastConfirmedMarkReadIdRef.current = Math.max( - lastConfirmedMarkReadIdRef.current, - lastReadMessageId, - ); - 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, registerNewMessagesBelow, removePendingMessageByServerMessage, scheduleMarkRead, scrollToBottom, syncLatestMessages, updateMessageReactionsSummary]); - - // Очистка таймаута при размонтировании - useEffect(() => { - return () => { - if (typingTimeoutRef.current) { - clearTimeout(typingTimeoutRef.current); - } - if (floatingDateTimeoutRef.current) { - clearTimeout(floatingDateTimeoutRef.current); - } - if (markReadTimerRef.current) { - clearTimeout(markReadTimerRef.current); - } - clearAllPendingTimers(); - }; - }, [clearAllPendingTimers]); - - 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) => { @@ -852,7 +208,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) { @@ -860,406 +215,234 @@ 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)) { - clearAllPendingTimers(); - setMessages([]); - setPendingMessages([]); - setHasMoreOlder(false); - setHasMoreNewer(false); - setInitialAnchorId(null); - setInitialAnchorIndex(null); - setAllowOneOlderProbe(false); - initialScrolledRef.current = false; - resetNewMessagesBelow(); - 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) { - clearAllPendingTimers(); - const normalized = uniqueMessagesById(aroundMessages); - setMessages(normalized); - setPendingMessages([]); - // 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)); - unreadBelowMessageIdsRef.current = new Set( - normalized - .filter((message) => { - const authorId = message.author_id ?? message.author?.id ?? message.sender?.id; - return authorId !== user?.id && message.id > (lastReadId ?? 0); - }) - .map((message) => message.id) - ); - setNewMessagesBelowCount(Math.max(chatDetails.unread_count ?? unreadBelowMessageIdsRef.current.size, 0)); - } else { - clearAllPendingTimers(); - const response = await apiClient.getChatMessages(chatId, { limit: 50 }); - const normalized = uniqueMessagesById(response.messages || []); - setMessages(normalized); - setPendingMessages([]); - setHasMoreOlder(Boolean(response.has_more)); - setHasMoreNewer(false); - setInitialAnchorId(null); - setInitialAnchorIndex(null); - setAllowOneOlderProbe(false); - unreadBelowMessageIdsRef.current = new Set( - normalized - .filter((message) => { - const authorId = message.author_id ?? message.author?.id ?? message.sender?.id; - return authorId !== user?.id && message.id > (lastReadId ?? 0); - }) - .map((message) => message.id) - ); - setNewMessagesBelowCount(Math.max(chatDetails.unread_count ?? unreadBelowMessageIdsRef.current.size, 0)); } - 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("Нет доступа к этому чату"); - clearAllPendingTimers(); - setMessages([]); - setPendingMessages([]); - } 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); - resetNewMessagesBelow(); - initialScrolledRef.current = false; - anchorScrollAttemptsRef.current = 0; - anchorOlderLoadAttemptsRef.current = 0; - setAnchorRetryTick(0); - } finally { - setMessagesLoading(false); } - } + }; - loadMessages(); - }, [chatId, clearAllPendingTimers, resetNewMessagesBelow, 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); + }, []); + + 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 || displayMessages.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); - syncScrollToBottomState(); - 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); - syncScrollToBottomState(); - 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'); - syncScrollToBottomState(); - initialScrolledRef.current = true; - anchorScrollAttemptsRef.current = 0; - anchorOlderLoadAttemptsRef.current = 0; - }, [ - messagesLoading, - displayMessages, - initialAnchorId, - initialAnchorIndex, - anchorRetryTick, - syncScrollToBottomState, - ]); - - // Удалено: prependAdjustRef useLayoutEffect - // ScrollableMessageList уже корректирует позицию автоматически через getSnapshotBeforeUpdate - // Дублирующая корректировка вызывала "подергивание" при загрузке старых сообщений - - 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])); - } - - 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 = !historyNavigationMode && isNearBottom(); - - try { - setLoadingNewer(true); - const response = await apiClient.getChatMessages(chatId, { - limit: 10, // Ограничено для минимальной погрешности автоотметки (было 40) - after_id: newestMessage.id, - mark_read: false, - }); - - const newerMessages = response.messages || []; - if (newerMessages.length > 0) { - newerMessages.forEach(removePendingMessageByServerMessage); - setMessages((prev) => uniqueMessagesById([...prev, ...newerMessages])); - - if (shouldMarkRead) { - const lastLoadedMessage = newerMessages[newerMessages.length - 1]; - scheduleMarkRead(lastLoadedMessage.id); - } - - // НЕ делаем автоскролл - ScrollableMessageList сам решит: - // - Если был внизу (sticky) → автоматически проскроллит вниз - // - Если был НЕ внизу → сохранит текущую позицию - // Это правильное поведение для дозагрузки истории - } - - setHasMoreNewer(Boolean(response.has_more)); - } catch (e) { - console.error("Ошибка подгрузки новых сообщений:", e); - } finally { - setLoadingNewer(false); - } - }, [chatId, hasMoreNewer, historyNavigationMode, isNearBottom, loadingNewer, messages, messagesLoading, removePendingMessageByServerMessage, scheduleMarkRead]); - - useChatFallbackSync({ - chatId: chatId || null, - isConnected, - userLoading, - hasUser: Boolean(user), - messagesLoading, - hasMessages: messages.length > 0, - isNearBottom, - syncLatestMessages, - }); + component.scrollToBottom("auto"); + scroll.syncScrollToBottomState(); + cm.markInitialScrollDone(); + }, [cm, displayMessages, scroll]); + /* ── scroll handler: pagination + mark-read ── */ useEffect(() => { - const component = messagesViewportRef.current; - if (!component || !component.containerRef?.current) return; - - const viewport = component.containerRef.current; + const c = messagesViewportRef.current; + if (!c?.containerRef?.current) return; + const viewport = c.containerRef.current; const onScroll = () => { - // Загрузка старых сообщений при приближении к верху - if (!loadingOlder && !messagesLoading && hasMoreOlder && viewport.scrollTop <= 120) { - loadOlderMessages(); + if (!cm.loadingOlder && !cm.messagesLoading && cm.hasMoreOlder && viewport.scrollTop <= 120) { + cm.loadOlderMessages(); } - // Загрузка новых сообщений при приближении к низу (щедрый порог для предзагрузки) const distFromBottom = viewport.scrollHeight - viewport.scrollTop - viewport.clientHeight; - if (!loadingNewer && !messagesLoading && hasMoreNewer && distFromBottom <= 300) { - loadNewerMessages(); + if (!cm.loadingNewer && !cm.messagesLoading && cm.hasMoreNewer && distFromBottom <= 300) { + cm.loadNewerMessages(); } - const messageEls = viewport.querySelectorAll('[data-message-date]'); - const viewportTop = viewport.getBoundingClientRect().top; - let topDate: string | null = null; - let topDateValue: string | null = null; + const isAtBottom = scroll.syncScrollToBottomState(); - for (let i = 0; i < messageEls.length; i += 1) { - const el = messageEls[i]; - const rect = el.getBoundingClientRect(); - - if (rect.bottom > viewportTop + 8) { - const isoDate = el.getAttribute('data-message-date'); - if (isoDate) { - const date = new Date(isoDate); - if (!Number.isNaN(date.getTime())) { - topDate = formatDayDivider(date); - topDateValue = formatDateInputValue(date); - } - } - break; - } - } - - if (topDate) { - setFloatingDate(topDate); - setFloatingDateValue(topDateValue); - setShowFloatingDate(true); - - if (floatingDateTimeoutRef.current) { - clearTimeout(floatingDateTimeoutRef.current); - } - - floatingDateTimeoutRef.current = setTimeout(() => { - setShowFloatingDate(false); - }, 1100); - } - - // Показ кнопки scroll-to-bottom когда не внизу - const isAtBottom = syncScrollToBottomState(); - - if (isAtBottom && !hasMoreNewer && messages.length > 0) { - const latestLoadedMessage = messages[messages.length - 1]; - scheduleMarkRead(latestLoadedMessage.id); + 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); - }, [chatId, displayMessages, hasMoreOlder, hasMoreNewer, loadNewerMessages, loadOlderMessages, loadingOlder, loadingNewer, messages, messagesLoading, scheduleMarkRead, syncScrollToBottomState]); + }, [cm, markRead, scroll]); + + /* ── fallback sync ── */ + useChatFallbackSync({ + chatId: chatId || null, + isConnected, + userLoading, + hasUser: Boolean(user), + messagesLoading: cm.messagesLoading, + hasMessages: cm.messages.length > 0, + isNearBottom: scroll.isNearBottom, + syncLatestMessages: cm.syncLatestMessages, + }); + + /* ── composer helpers ── */ + + 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(); + input.setSelectionRange(input.value.length, input.value.length); + }; + + 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; - } + if (editingMessageId) { if (!text) return; } + else if (!text && attachedFiles.length === 0) return; - // Запоминаем позицию ДО отправки - const wasNearBottom = isNearBottom(); + const wasNearBottom = scroll.isNearBottom(); try { if (editingMessageId) { setSending(true); const updated = await apiClient.updateMessage(editingMessageId, text); - setMessages((prev) => prev.map((m) => (m.id === editingMessageId ? { ...m, ...updated } : m))); + cm.setMessages(prev => prev.map(m => m.id === editingMessageId ? { ...m, ...updated } : m)); resetComposerState(); } else { const files = [...attachedFiles]; @@ -1268,148 +451,79 @@ export default function MessageDialogPage() { ? crypto.randomUUID() : `local-${Date.now()}-${Math.random().toString(16).slice(2)}`; optimisticLocalId = localId; - const optimisticMessage = buildOptimisticMessage(text, files, localId); - - setPendingMessages((prev) => [...prev, optimisticMessage]); - schedulePendingTimer(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); - removePendingMessage(localId); - setMessages((prev) => uniqueMessagesById([...prev, sent])); - setHasMoreNewer(false); + cm.removePendingMessage(localId); + cm.setMessages(prev => uniqueMessagesById([...prev, sent])); - if (wasNearBottom) { - requestAnimationFrame(() => { - scrollToBottom(false); - }); - } + if (wasNearBottom) requestAnimationFrame(() => scroll.scrollToBottom(false)); return; } - // Автоскролл вниз ТОЛЬКО если был внизу при отправке - // Если читал историю вверху - не скроллим, оставляем там где был - if (wasNearBottom) { - requestAnimationFrame(() => { - scrollToBottom(false); - }); - } + if (wasNearBottom) requestAnimationFrame(() => scroll.scrollToBottom(false)); } catch (e) { console.error("Ошибка отправки сообщения:", e); - if (!editingMessageId && optimisticLocalId) { - clearPendingTimer(optimisticLocalId); - setPendingMessages((prev) => - prev.map((message) => - message.local_id === optimisticLocalId - ? { ...message, send_state: "failed" } - : message - ) - ); - } + if (!editingMessageId && optimisticLocalId) cm.markPendingFailed(optimisticLocalId); } finally { - if (editingMessageId) { - setSending(false); - } + if (editingMessageId) setSending(false); } }; - const handlePickFiles = () => { - if (editingMessageId) return; - fileInputRef.current?.click(); - }; + /* ── file handling ── */ const addAttachedFiles = useCallback((files: File[]) => { - if (editingMessageId || files.length === 0) { - return; - } - - setAttachedFiles((prev) => [...prev, ...files]); + if (editingMessageId || files.length === 0) return; + setAttachedFiles(prev => [...prev, ...files]); }, [editingMessageId]); - const handleFilesChange = (e: React.ChangeEvent) => { - const files = Array.from(e.target.files || []); - addAttachedFiles(files); - }; + 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(); }; - const removeAttachedFile = (index: number) => { - setAttachedFiles((prev) => prev.filter((_, i) => i !== index)); - }; - - const focusMessageInput = () => { - const input = messageInputRef.current; - if (!input) return; - input.focus(); - const len = input.value.length; - input.setSelectionRange(len, len); - }; - - 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); - setIsReadersModalOpen(false); - setActionsMenuAnchor(null); - requestAnimationFrame(focusMessageInput); - }; + /* ── 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); @@ -1419,13 +533,8 @@ export default function MessageDialogPage() { }; const handleToggleMessageActions = useCallback((messageId: number, anchor: MessageActionsAnchor) => { - setExpandedReplyActionForId((prev) => { - if (prev === messageId) { - setIsReadersModalOpen(false); - setActionsMenuAnchor(null); - return null; - } - + setExpandedReplyActionForId(prev => { + if (prev === messageId) { setIsReadersModalOpen(false); setActionsMenuAnchor(null); return null; } setIsReadersModalOpen(false); setReactionPickerForMessageId(null); setShowComposerEmojiPicker(false); @@ -1434,207 +543,81 @@ 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 })); - }, []); - - const handleUseOriginalImage = useCallback((attachmentId: number) => { - setUseOriginalImage((prev) => ({ ...prev, [attachmentId]: true })); - }, []); - - const openDateNavigator = useCallback((dateValue?: string | null) => { - setSelectedHistoryDate(dateValue || floatingDateValue || formatDateInputValue(new Date())); - setShowFloatingDate(true); - setIsDateNavigatorOpen(true); - }, [floatingDateValue]); + /* ── date navigation ── */ const handleJumpToDate = useCallback(async (dateValue: string) => { - if (!chatId || Number.isNaN(chatId) || !dateValue || jumpingToDate) { - return; - } - - const dayStart = new Date(`${dateValue}T00:00:00`); - if (Number.isNaN(dayStart.getTime())) { - return; - } - - try { - setJumpingToDate(true); - setHistoryNavigationMode(true); - setMessagesLoading(true); - setIsDateNavigatorOpen(false); - - const [olderResponse, newerResponse] = await Promise.all([ - apiClient.getChatMessages(chatId, { - limit: 20, - before: dayStart.toISOString(), - }), - apiClient.getChatMessages(chatId, { - limit: 20, - after: dayStart.toISOString(), - mark_read: false, - }), - ]); - - const olderMessages = olderResponse.messages || []; - const newerMessages = newerResponse.messages || []; - const combinedMessages = uniqueMessagesById([...olderMessages, ...newerMessages]); - const anchorMessage = newerMessages[0] || olderMessages[olderMessages.length - 1] || combinedMessages[0] || null; - - if (!anchorMessage) { - return; - } - - setMessages(combinedMessages); - setHasMoreOlder(Boolean(olderResponse.has_more)); - setHasMoreNewer(Boolean(newerResponse.has_more)); - setInitialAnchorId(anchorMessage.id); - setInitialAnchorIndex(combinedMessages.findIndex((message) => message.id === anchorMessage.id)); - setAllowOneOlderProbe(Boolean(newerMessages[0])); - - const anchorDate = getMessageDate(anchorMessage); - if (anchorDate) { - setFloatingDate(formatDayDivider(anchorDate)); - setFloatingDateValue(formatDateInputValue(anchorDate)); - setShowFloatingDate(true); - } - - initialScrolledRef.current = false; - anchorScrollAttemptsRef.current = 0; - anchorOlderLoadAttemptsRef.current = 0; - setAnchorRetryTick(0); - resetNewMessagesBelow(); - } catch (jumpError) { - console.error("Ошибка перехода к дате:", jumpError); - } finally { - setMessagesLoading(false); - setJumpingToDate(false); - } - }, [chatId, jumpingToDate, resetNewMessagesBelow]); + if (jumpingToDate) return; + setJumpingToDate(true); + scroll.setIsDateNavigatorOpen(false); + await cm.jumpToDate(dateValue); + setJumpingToDate(false); + }, [cm, jumpingToDate, scroll]); const handleReturnToNewMessages = useCallback(async () => { - if (!chatId || Number.isNaN(chatId)) { + const c = messagesViewportRef.current?.containerRef?.current; + + if (!cm.hasMoreNewer && !cm.historyNavigationMode) { + cm.setHistoryNavigationMode(false); + if (c) c.scrollTop = c.scrollHeight; + cm.resetNewMessagesBelow(); return; } - const component = messagesViewportRef.current; - const container = component?.containerRef?.current; - const lastReadMessageId = Math.max(chat?.last_read_message_id ?? 0, lastConfirmedMarkReadIdRef.current); - const shouldRestoreUnreadBoundary = (hasMoreNewer || historyNavigationMode) && lastReadMessageId > 0; - - if (!shouldRestoreUnreadBoundary) { - setHistoryNavigationMode(false); - if (container) { - container.scrollTop = container.scrollHeight; - } - 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]); - try { - setMessagesLoading(true); - - const around = await apiClient.getChatMessagesAround(chatId, { - limit: 30, - around_id: lastReadMessageId, - }); - - const normalized = uniqueMessagesById(around.messages || []); - if (normalized.length === 0) { - if (container) { - container.scrollTop = container.scrollHeight; - } - setHistoryNavigationMode(false); - resetNewMessagesBelow(); - return; - } - - setMessages(normalized); - setHasMoreOlder( - typeof around.has_more_before === "boolean" ? around.has_more_before : normalized.length >= 50 - ); - setHasMoreNewer(Boolean(around.has_more_after)); - setInitialAnchorId(around.anchor_id ?? lastReadMessageId); - setInitialAnchorIndex(typeof around.anchor_index === "number" ? around.anchor_index : null); - setAllowOneOlderProbe(Boolean(around.anchor_id ?? lastReadMessageId)); - setHistoryNavigationMode(Boolean(around.has_more_after)); - setShowScrollToBottom(true); - - initialScrolledRef.current = false; - anchorScrollAttemptsRef.current = 0; - anchorOlderLoadAttemptsRef.current = 0; - setAnchorRetryTick(0); - } catch (error) { - console.error("Ошибка возврата к непрочитанным сообщениям:", error); - } finally { - setMessagesLoading(false); - } - }, [chat?.last_read_message_id, chatId, hasMoreNewer, historyNavigationMode, resetNewMessagesBelow]); + /* ── 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(".reaction-picker-modal")) return; - if (target.closest("[data-composer-emoji='true']")) return; - if (target.closest("[data-date-navigator='true']")) return; - if (target.closest("[data-date-trigger='true']")) return; + /* ── 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); - setIsDateNavigatorOpen(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) => { @@ -1644,74 +627,64 @@ export default function MessageDialogPage() { setActionsMenuAnchor(null); setReactionPickerForMessageId(null); setShowComposerEmojiPicker(false); - setIsDateNavigatorOpen(false); + scroll.setIsDateNavigatorOpen(false); } }; window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); - }, []); + }, [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("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 ? ( <>
- {floatingDate ? ( -
-
) : null} - {isDateNavigatorOpen ? ( + {/* Date navigator panel */} + {scroll.isDateNavigatorOpen ? (

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

- - setSelectedHistoryDate(event.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" - /> - + + 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 ? (

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

) : displayMessages.length === 0 ? (

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

) : (
- {loadingOlder ? ( -

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

- ) : null} + {cm.loadingOlder ?

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

: null} {displayMessages.map((message, index) => { const replyToId = getReplyToId(message); const repliedMessage = replyToId ? messagesById.get(replyToId) : null; - const currentDayKey = getMessageDayKey(message); - const previousDayKey = index > 0 ? getMessageDayKey(displayMessages[index - 1]) : null; - const currentMessageDate = getMessageDate(message); - const showDayDivider = Boolean(currentMessageDate && currentDayKey !== previousDayKey); + 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 && currentMessageDate ? ( + {showDayDivider && curDate ? (
-
) : null} @@ -1850,35 +776,34 @@ export default function MessageDialogPage() { )} - {/* Кнопка прокрутки вниз */} - {showScrollToBottom && ( + {/* Scroll-to-bottom button */} + {scroll.showScrollToBottom && (
)} + {/* Composer area */}
{isTyping && (
- - - + + +
Собеседник печатает...
@@ -1901,15 +826,12 @@ export default function MessageDialogPage() { 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} @@ -1942,12 +864,7 @@ export default function MessageDialogPage() { canReply={selectedActionCanReply} canManage={selectedActionCanManage} recentReactions={selectedActionRecentReactions} - onQuickReact={(emoji) => { - handleReact(selectedActionMessage, emoji); - setExpandedReplyActionForId(null); - setIsReadersModalOpen(false); - setActionsMenuAnchor(null); - }} + onQuickReact={(emoji) => { handleReact(selectedActionMessage, emoji); setExpandedReplyActionForId(null); setIsReadersModalOpen(false); setActionsMenuAnchor(null); }} onOpenReactionPicker={() => setReactionPickerForMessageId(selectedActionMessage.id)} onShowAllReaders={() => setIsReadersModalOpen(true)} onReply={() => handleReplyToMessage(selectedActionMessage)} @@ -1957,21 +874,15 @@ export default function MessageDialogPage() { ) : null} {selectedActionMessage ? ( - setIsReadersModalOpen(false)} - readers={selectedActionReaders} - /> + 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/hooks/useChatMessages.ts b/frontend/src/hooks/useChatMessages.ts new file mode 100644 index 00000000..97a154bb --- /dev/null +++ b/frontend/src/hooks/useChatMessages.ts @@ -0,0 +1,576 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { apiClient } from "@/lib/api"; +import type { Chat, Message } from "@/types/api"; +import { getMessageTimestamp, uniqueMessagesById } from "@/lib/messages/chatUtils"; +import { getReplyToId } from "@/lib/messages/messageUtils"; + +const PENDING_MESSAGE_DELAY_MS = 8000; + +/* ─── public types ─── */ + +export interface UseChatMessagesOptions { + chatId: number; + userId: number | undefined; + userLoading: boolean; +} + +export interface UseChatMessagesReturn { + chat: Chat | null; + setChat: React.Dispatch>; + messages: Message[]; + setMessages: React.Dispatch>; + pendingMessages: Message[]; + loading: boolean; + messagesLoading: boolean; + error: string | null; + hasMoreOlder: boolean; + hasMoreNewer: boolean; + loadingOlder: boolean; + loadingNewer: boolean; + initialAnchorId: number | null; + initialAnchorIndex: number | null; + allowOneOlderProbe: boolean; + anchorRetryTick: number; + /** Whether we are in "date navigation" (history) mode */ + historyNavigationMode: boolean; + setHistoryNavigationMode: React.Dispatch>; + /** Number of new (unread) messages that appeared below the viewport */ + newMessagesBelowCount: number; + /** Ref containing unread-below message ids */ + unreadBelowIdsRef: React.MutableRefObject>; + /** hasMoreNewer kept in a ref for synchronous access */ + hasMoreNewerRef: React.MutableRefObject; + /** Load older (prepend) */ + loadOlderMessages: () => Promise; + /** Load newer (append) */ + loadNewerMessages: (opts?: { markRead?: boolean }) => Promise; + /** Background "fallback sync" of latest messages */ + syncLatestMessages: (opts?: { limit?: number; scrollOnSync?: boolean }) => Promise; + /** Register incoming messages as "below viewport" */ + registerNewMessagesBelow: (msgs: Message[]) => void; + /** Reset the "below viewport" counter & set */ + resetNewMessagesBelow: () => void; + /** Remove a pending message by its local_id */ + removePendingMessage: (localId?: string | null) => void; + /** Reconcile a server-confirmed message with its pending twin */ + removePendingByServer: (confirmed: Message) => void; + /** Create an optimistic message for instant UI */ + buildOptimistic: (text: string, files: File[], localId: string, replyToId?: number) => Message; + /** Append a pending message */ + addPending: (msg: Message) => void; + /** Start the "delayed" timer for a pending message */ + schedulePendingTimer: (localId: string) => void; + /** Mark a pending message as failed */ + markPendingFailed: (localId: string) => void; + /** Jump to a specific date (history navigation) */ + jumpToDate: (dateISO: string) => Promise; + /** Return to unread boundary (around last_read_message_id) */ + returnToUnread: () => Promise<{ + hasMoreAfter: boolean; + anchorId: number | null; + anchorIndex: number | null; + } | null>; + /** Set anchorRetryTick (used to retry anchor scroll) */ + setAnchorRetryTick: React.Dispatch>; + /** Mark initial scroll as done */ + markInitialScrollDone: () => void; + /** Whether initial scroll still pending */ + isInitialScrollPending: () => boolean; + /** Ref to isNearBottom callback (set by scroll hook) */ + isNearBottomRef: React.MutableRefObject<() => boolean>; + /** Ref to scrollToBottom callback (set by scroll hook) */ + scrollToBottomRef: React.MutableRefObject<(smooth?: boolean) => void>; +} + +/* ─── hook ─── */ + +export function useChatMessages({ + chatId, + userId, + userLoading, +}: UseChatMessagesOptions): UseChatMessagesReturn { + /* ── core state ── */ + const [chat, setChat] = useState(null); + const [messages, setMessages] = useState([]); + const [pendingMessages, setPendingMessages] = useState([]); + const [loading, setLoading] = useState(true); + const [messagesLoading, setMessagesLoading] = useState(false); + const [error, setError] = useState(null); + + /* ── pagination ── */ + const [hasMoreOlder, setHasMoreOlder] = useState(false); + const [hasMoreNewer, setHasMoreNewer] = useState(false); + const [loadingOlder, setLoadingOlder] = useState(false); + const [loadingNewer, setLoadingNewer] = useState(false); + + /* ── anchor / history ── */ + const [initialAnchorId, setInitialAnchorId] = useState(null); + const [initialAnchorIndex, setInitialAnchorIndex] = useState(null); + const [allowOneOlderProbe, setAllowOneOlderProbe] = useState(false); + const [anchorRetryTick, setAnchorRetryTick] = useState(0); + const [historyNavigationMode, setHistoryNavigationMode] = useState(false); + + /* ── unread tracking ── */ + const [newMessagesBelowCount, setNewMessagesBelowCount] = useState(0); + const unreadBelowIdsRef = useRef>(new Set()); + + /* ── refs ── */ + const hasMoreNewerRef = useRef(false); + const initialScrolledRef = useRef(false); + const pendingMessagesRef = useRef([]); + const localSeqRef = useRef(0); + const pendingTimersRef = useRef>(new Map()); + const fallbackInFlightRef = useRef(false); + + /** Injected by scroll hook */ + const isNearBottomRef = useRef<() => boolean>(() => false); + const scrollToBottomRef = useRef<(smooth?: boolean) => void>(() => {}); + + /* keep refs in sync */ + useEffect(() => { hasMoreNewerRef.current = hasMoreNewer; }, [hasMoreNewer]); + useEffect(() => { pendingMessagesRef.current = pendingMessages; }, [pendingMessages]); + + /* ── pending message helpers ── */ + + const clearPendingTimer = useCallback((localId?: string | null) => { + if (!localId) return; + const t = pendingTimersRef.current.get(localId); + if (t) { clearTimeout(t); pendingTimersRef.current.delete(localId); } + }, []); + + const markPendingAsDelayed = useCallback((localId: string) => { + setPendingMessages(prev => + prev.map(m => m.local_id === localId && m.send_state === "pending" ? { ...m, send_state: "delayed" } : m), + ); + }, []); + + const schedulePendingTimer = useCallback((localId: string) => { + clearPendingTimer(localId); + const t = setTimeout(() => { pendingTimersRef.current.delete(localId); markPendingAsDelayed(localId); }, PENDING_MESSAGE_DELAY_MS); + pendingTimersRef.current.set(localId, t); + }, [clearPendingTimer, markPendingAsDelayed]); + + const removePendingMessage = useCallback((localId?: string | null) => { + if (!localId) return; + clearPendingTimer(localId); + setPendingMessages(prev => prev.filter(m => m.local_id !== localId)); + }, [clearPendingTimer]); + + const clearAllPendingTimers = useCallback(() => { + pendingTimersRef.current.forEach(t => clearTimeout(t)); + pendingTimersRef.current.clear(); + }, []); + + const markPendingFailed = useCallback((localId: string) => { + clearPendingTimer(localId); + setPendingMessages(prev => prev.map(m => m.local_id === localId ? { ...m, send_state: "failed" } : m)); + }, [clearPendingTimer]); + + /* ── attachment signature matching ── */ + + const getAttachmentSig = useCallback((msg: Message) => + (msg.attachments || []).map(a => `${a.file_name}|${a.file_size ?? 0}|${a.mime_type ?? ""}`).sort().join("||"), + []); + + const findMatchingPendingLocalId = useCallback((confirmed: Message) => { + if (!userId || confirmed.author_id !== userId) return null; + const confirmedReplyToId = getReplyToId(confirmed) ?? null; + const confirmedSig = getAttachmentSig(confirmed); + const confirmedTs = getMessageTimestamp(confirmed); + + const candidates = pendingMessagesRef.current + .filter(p => p.author_id === userId && p.content === confirmed.content) + .filter(p => (getReplyToId(p) ?? null) === confirmedReplyToId) + .filter(p => getAttachmentSig(p) === confirmedSig) + .map(p => ({ localId: p.local_id ?? null, diff: Math.abs(getMessageTimestamp(p) - confirmedTs) })) + .filter(c => Boolean(c.localId)) + .sort((a, b) => a.diff - b.diff); + + const best = candidates[0]; + if (!best) return null; + if (confirmedTs > 0 && best.diff > 10 * 60 * 1000) return null; + return best.localId; + }, [getAttachmentSig, userId]); + + const removePendingByServer = useCallback((confirmed: Message) => { + const localId = findMatchingPendingLocalId(confirmed); + if (localId) removePendingMessage(localId); + }, [findMatchingPendingLocalId, removePendingMessage]); + + /* ── unread below ── */ + + const resetNewMessagesBelow = useCallback(() => { + unreadBelowIdsRef.current.clear(); + setNewMessagesBelowCount(0); + }, []); + + const registerNewMessagesBelow = useCallback((incoming: Message[]) => { + if (!userId || incoming.length === 0) return; + let added = 0; + incoming.forEach(m => { + const authorId = m.author_id ?? m.author?.id ?? m.sender?.id; + if (authorId === userId || m.id <= 0 || unreadBelowIdsRef.current.has(m.id)) return; + unreadBelowIdsRef.current.add(m.id); + added += 1; + }); + if (added > 0) { + setNewMessagesBelowCount(prev => prev + added); + } + }, [userId]); + + /* ── optimistic message builder ── */ + + const guessType = useCallback((f: File) => { + const mime = (f.type || "").toLowerCase(); + if (mime.startsWith("image/")) return "image"; + if (mime.startsWith("video/")) return "video"; + if (mime.startsWith("audio/")) return "audio"; + return "file"; + }, []); + + const buildOptimistic = useCallback((text: string, files: File[], localId: string, replyToId?: number): Message => { + localSeqRef.current += 1; + const now = Date.now() + localSeqRef.current; + return { + id: -now, + chat: chatId, + local_id: localId, + author_id: userId, + content: text, + is_read: false, + send_state: "pending", + is_optimistic: true, + created_at: new Date(now).toISOString(), + created_ts: now, + has_attachments: files.length > 0, + attachments: files.map((f, i) => ({ + id: -(now * 100 + i), + file_name: f.name, + file_type: guessType(f), + file_url: "", + file_size: f.size, + mime_type: f.type, + is_local: true, + })), + reply_to_id: replyToId, + }; + }, [chatId, guessType, userId]); + + const addPending = useCallback((msg: Message) => { + setPendingMessages(prev => [...prev, msg]); + }, []); + + /* ── load chat ── */ + + useEffect(() => { + if (!chatId || Number.isNaN(chatId)) { setChat(null); setLoading(false); return; } + if (userLoading) { setLoading(true); return; } + if (!userId) { setChat(null); setLoading(false); return; } + + let cancelled = false; + (async () => { + try { + setLoading(true); + setError(null); + const loaded = await apiClient.getChat(chatId); + if (!cancelled) setChat(loaded); + } catch { + if (!cancelled) { setChat(null); setError("Не удалось загрузить чат. Проверьте подключение и попробуйте снова."); } + } finally { + if (!cancelled) setLoading(false); + } + })(); + return () => { cancelled = true; }; + }, [chatId, userLoading, userId]); + + /* ── load messages ── */ + + useEffect(() => { + if (!chatId || Number.isNaN(chatId)) { + clearAllPendingTimers(); setMessages([]); setPendingMessages([]); + setHasMoreOlder(false); setHasMoreNewer(false); + setInitialAnchorId(null); setInitialAnchorIndex(null); + setAllowOneOlderProbe(false); + initialScrolledRef.current = false; + resetNewMessagesBelow(); + return; + } + if (userLoading || !userId) return; + + let cancelled = false; + + (async () => { + try { + setMessagesLoading(true); + const chatDetails = await apiClient.getChat(chatId); + if (cancelled) return; + setChat(chatDetails); + const lastReadId = chatDetails.last_read_message_id; + + const around = await apiClient.getChatMessagesAround(chatId, { + limit: 30, + around_id: lastReadId || undefined, + }); + if (cancelled) return; + + const aroundMsgs = around.messages || []; + if (aroundMsgs.length > 0) { + clearAllPendingTimers(); + const norm = uniqueMessagesById(aroundMsgs); + setMessages(norm); + setPendingMessages([]); + setHasMoreOlder(typeof around.has_more_before === "boolean" ? around.has_more_before : norm.length >= 50); + 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)); + + // Seed unread-below from loaded messages + unreadBelowIdsRef.current = new Set( + norm.filter(m => { + const aId = m.author_id ?? m.author?.id ?? m.sender?.id; + return aId !== userId && m.id > (lastReadId ?? 0); + }).map(m => m.id), + ); + setNewMessagesBelowCount(Math.max(chatDetails.unread_count ?? 0, unreadBelowIdsRef.current.size)); + } else { + clearAllPendingTimers(); + const resp = await apiClient.getChatMessages(chatId, { limit: 50 }); + if (cancelled) return; + const norm = uniqueMessagesById(resp.messages || []); + setMessages(norm); setPendingMessages([]); + setHasMoreOlder(Boolean(resp.has_more)); setHasMoreNewer(false); + setInitialAnchorId(null); setInitialAnchorIndex(null); setAllowOneOlderProbe(false); + unreadBelowIdsRef.current = new Set( + norm.filter(m => { + const aId = m.author_id ?? m.author?.id ?? m.sender?.id; + return aId !== userId && m.id > (lastReadId ?? 0); + }).map(m => m.id), + ); + setNewMessagesBelowCount(Math.max(chatDetails.unread_count ?? 0, unreadBelowIdsRef.current.size)); + } + + initialScrolledRef.current = false; + setAnchorRetryTick(0); + } catch (err: unknown) { + if (cancelled) return; + const msg = err instanceof Error ? err.message : String(err); + if (msg.includes("403")) { + setError("Нет доступа к этому чату"); + clearAllPendingTimers(); setMessages([]); setPendingMessages([]); + } else { + console.error("Ошибка загрузки сообщений:", err); + } + setHasMoreOlder(false); setHasMoreNewer(false); + setInitialAnchorId(null); setInitialAnchorIndex(null); setAllowOneOlderProbe(false); + resetNewMessagesBelow(); + initialScrolledRef.current = false; + setAnchorRetryTick(0); + } finally { + if (!cancelled) setMessagesLoading(false); + } + })(); + + return () => { cancelled = true; }; + }, [chatId, clearAllPendingTimers, resetNewMessagesBelow, userLoading, userId]); + + // Cleanup timers on unmount + useEffect(() => () => { clearAllPendingTimers(); }, [clearAllPendingTimers]); + + // Reset per-chat state + useEffect(() => { + setHistoryNavigationMode(false); + resetNewMessagesBelow(); + }, [chatId, resetNewMessagesBelow]); + + /* ── pagination ── */ + + const loadOlderMessages = useCallback(async () => { + if (!chatId || Number.isNaN(chatId) || loadingOlder || messagesLoading || messages.length === 0) return; + if (!hasMoreOlder && !allowOneOlderProbe) return; + + const oldest = messages[0]; + try { + setLoadingOlder(true); + const resp = await apiClient.getChatMessages(chatId, { limit: 40, before_id: oldest.id }); + const older = resp.messages || []; + if (older.length > 0) setMessages(prev => uniqueMessagesById([...older, ...prev])); + setHasMoreOlder(Boolean(resp.has_more)); + setAllowOneOlderProbe(false); + } catch (e) { + console.error("Ошибка подгрузки старых сообщений:", e); + } finally { + setLoadingOlder(false); + } + }, [allowOneOlderProbe, chatId, hasMoreOlder, loadingOlder, messages, messagesLoading]); + + const loadNewerMessages = useCallback(async (opts?: { markRead?: boolean }) => { + if (!chatId || Number.isNaN(chatId) || loadingNewer || messagesLoading || !hasMoreNewer || messages.length === 0) return; + + const newest = messages[messages.length - 1]; + try { + setLoadingNewer(true); + const resp = await apiClient.getChatMessages(chatId, { limit: 10, after_id: newest.id, mark_read: false }); + const newer = resp.messages || []; + if (newer.length > 0) { + newer.forEach(removePendingByServer); + setMessages(prev => uniqueMessagesById([...prev, ...newer])); + } + setHasMoreNewer(Boolean(resp.has_more)); + } catch (e) { + console.error("Ошибка подгрузки новых сообщений:", e); + } finally { + setLoadingNewer(false); + } + }, [chatId, hasMoreNewer, loadingNewer, messages, messagesLoading, removePendingByServer]); + + /* ── fallback sync ── */ + + const syncLatestMessages = useCallback(async (options: { limit?: number; scrollOnSync?: boolean } = {}) => { + if (!chatId || Number.isNaN(chatId) || messagesLoading || messages.length === 0 || fallbackInFlightRef.current) return; + + const newest = messages[messages.length - 1]; + fallbackInFlightRef.current = true; + + try { + const resp = await apiClient.getChatMessages(chatId, { limit: options.limit ?? 20, after_id: newest.id, mark_read: false }); + const newer = resp.messages || []; + if (newer.length > 0) { + newer.forEach(removePendingByServer); + setMessages(prev => uniqueMessagesById([...prev, ...newer])); + + if (options.scrollOnSync) { + requestAnimationFrame(() => scrollToBottomRef.current(false)); + } + } + setHasMoreNewer(Boolean(resp.has_more)); + } catch (e) { + console.error("Ошибка тихой синхронизации сообщений:", e); + } finally { + fallbackInFlightRef.current = false; + } + }, [chatId, messages, messagesLoading, removePendingByServer]); + + /* ── jump to date ── */ + + const jumpToDate = useCallback(async (dateValue: string) => { + if (!chatId || Number.isNaN(chatId) || !dateValue) return; + const dayStart = new Date(`${dateValue}T00:00:00`); + if (Number.isNaN(dayStart.getTime())) return; + + try { + setMessagesLoading(true); + setHistoryNavigationMode(true); + + const [olderResp, newerResp] = await Promise.all([ + apiClient.getChatMessages(chatId, { limit: 20, before: dayStart.toISOString() }), + apiClient.getChatMessages(chatId, { limit: 20, after: dayStart.toISOString(), mark_read: false }), + ]); + + const combined = uniqueMessagesById([...(olderResp.messages || []), ...(newerResp.messages || [])]); + const anchor = (newerResp.messages || [])[0] || (olderResp.messages || []).at(-1) || combined[0] || null; + if (!anchor) return; + + setMessages(combined); + setHasMoreOlder(Boolean(olderResp.has_more)); + setHasMoreNewer(Boolean(newerResp.has_more)); + setInitialAnchorId(anchor.id); + setInitialAnchorIndex(combined.findIndex(m => m.id === anchor.id)); + setAllowOneOlderProbe(Boolean((newerResp.messages || [])[0])); + + initialScrolledRef.current = false; + setAnchorRetryTick(0); + } catch (e) { + console.error("Ошибка перехода к дате:", e); + } finally { + setMessagesLoading(false); + } + }, [chatId]); + + /* ── return to unread boundary ── */ + + const returnToUnread = useCallback(async () => { + if (!chatId || Number.isNaN(chatId)) return null; + + const lastReadMessageId = Math.max(chat?.last_read_message_id ?? 0, 0); + const shouldRestore = (hasMoreNewer || historyNavigationMode) && lastReadMessageId > 0; + + if (!shouldRestore) return null; + + try { + setMessagesLoading(true); + const around = await apiClient.getChatMessagesAround(chatId, { limit: 30, around_id: lastReadMessageId }); + const norm = uniqueMessagesById(around.messages || []); + if (norm.length === 0) return null; + + setMessages(norm); + setHasMoreOlder(typeof around.has_more_before === "boolean" ? around.has_more_before : norm.length >= 50); + setHasMoreNewer(Boolean(around.has_more_after)); + setInitialAnchorId(around.anchor_id ?? lastReadMessageId); + setInitialAnchorIndex(typeof around.anchor_index === "number" ? around.anchor_index : null); + setAllowOneOlderProbe(Boolean(around.anchor_id ?? lastReadMessageId)); + setHistoryNavigationMode(Boolean(around.has_more_after)); + + initialScrolledRef.current = false; + setAnchorRetryTick(0); + + return { + hasMoreAfter: Boolean(around.has_more_after), + anchorId: around.anchor_id ?? lastReadMessageId, + anchorIndex: typeof around.anchor_index === "number" ? around.anchor_index : null, + }; + } catch (e) { + console.error("Ошибка возврата к непрочитанным сообщениям:", e); + return null; + } finally { + setMessagesLoading(false); + } + }, [chat?.last_read_message_id, chatId, hasMoreNewer, historyNavigationMode]); + + /* ── scroll tracking helpers ── */ + + const markInitialScrollDone = useCallback(() => { initialScrolledRef.current = true; }, []); + const isInitialScrollPending = useCallback(() => !initialScrolledRef.current, []); + + return { + chat, + setChat, + messages, + setMessages, + pendingMessages, + loading, + messagesLoading, + error, + hasMoreOlder, + hasMoreNewer, + loadingOlder, + loadingNewer, + initialAnchorId, + initialAnchorIndex, + allowOneOlderProbe, + anchorRetryTick, + historyNavigationMode, + setHistoryNavigationMode, + newMessagesBelowCount, + unreadBelowIdsRef, + hasMoreNewerRef, + loadOlderMessages, + loadNewerMessages, + syncLatestMessages, + registerNewMessagesBelow, + resetNewMessagesBelow, + removePendingMessage, + removePendingByServer, + buildOptimistic, + addPending, + schedulePendingTimer, + markPendingFailed, + jumpToDate, + returnToUnread, + setAnchorRetryTick, + markInitialScrollDone, + isInitialScrollPending, + isNearBottomRef, + scrollToBottomRef, + }; +} diff --git a/frontend/src/hooks/useChatScroll.ts b/frontend/src/hooks/useChatScroll.ts new file mode 100644 index 00000000..ea875a65 --- /dev/null +++ b/frontend/src/hooks/useChatScroll.ts @@ -0,0 +1,194 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { formatDayDivider } from "@/lib/messages/messageUtils"; +import type { ScrollableMessageListInner } from "@/components/ScrollableMessageList"; + +/* ─── helpers ─── */ + +function formatDateInputValue(date: Date): string { + const year = date.getFullYear(); + const month = `${date.getMonth() + 1}`.padStart(2, "0"); + const day = `${date.getDate()}`.padStart(2, "0"); + return `${year}-${month}-${day}`; +} + +export function shiftDateInputValue(value: string, deltaDays: number): string { + const baseDate = value ? new Date(`${value}T00:00:00`) : new Date(); + if (Number.isNaN(baseDate.getTime())) return formatDateInputValue(new Date()); + baseDate.setDate(baseDate.getDate() + deltaDays); + return formatDateInputValue(baseDate); +} + +/* ─── types ─── */ + +export interface UseChatScrollOptions { + chatId: number; + /** Number of rendered messages, used to recalculate floating date after reload/jump */ + messageCount?: number; + /** Ref to the scroll container component */ + viewportRef: React.RefObject; + /** Whether there are more newer messages to load */ + hasMoreNewerRef: React.MutableRefObject; + /** Ref to unread-below message ids */ + unreadBelowIdsRef: React.MutableRefObject>; + /** Whether we are in history navigation mode */ + historyNavigationMode: boolean; +} + +export interface UseChatScrollReturn { + /** Whether the scroll-to-bottom button should be visible */ + showScrollToBottom: boolean; + setShowScrollToBottom: React.Dispatch>; + /** Floating date display */ + floatingDate: string | null; + floatingDateValue: string | null; + showFloatingDate: boolean; + /** Date navigator state */ + isDateNavigatorOpen: boolean; + setIsDateNavigatorOpen: React.Dispatch>; + selectedHistoryDate: string; + setSelectedHistoryDate: React.Dispatch>; + /** Check if user is near bottom */ + isNearBottom: () => boolean; + /** Scroll to bottom */ + scrollToBottom: (smooth?: boolean) => void; + /** Synchronize the scroll-to-bottom button visibility. Returns true if at bottom. */ + syncScrollToBottomState: () => boolean; + /** Open date navigator, optionally with a preset date */ + openDateNavigator: (dateValue?: string | null) => void; +} + +/* ─── hook ─── */ + +export function useChatScroll({ + chatId, + messageCount = 0, + viewportRef, + hasMoreNewerRef, + unreadBelowIdsRef, + historyNavigationMode, +}: UseChatScrollOptions): UseChatScrollReturn { + const [showScrollToBottom, setShowScrollToBottom] = useState(false); + const [floatingDate, setFloatingDate] = useState(null); + const [floatingDateValue, setFloatingDateValue] = useState(null); + const [showFloatingDate, setShowFloatingDate] = useState(false); + const [isDateNavigatorOpen, setIsDateNavigatorOpen] = useState(false); + const [selectedHistoryDate, setSelectedHistoryDate] = useState(""); + + const floatingDateTimeoutRef = useRef(null); + + // Sync selected date from floating date when navigator is closed + useEffect(() => { + if (floatingDateValue && !isDateNavigatorOpen) { + setSelectedHistoryDate(floatingDateValue); + } + }, [floatingDateValue, isDateNavigatorOpen]); + + // Cleanup + useEffect(() => () => { + if (floatingDateTimeoutRef.current) clearTimeout(floatingDateTimeoutRef.current); + }, []); + + const isNearBottom = useCallback(() => { + const c = viewportRef.current; + if (!c?.containerRef?.current) return false; + const v = c.containerRef.current; + return v.scrollHeight - v.scrollTop - v.clientHeight <= 150; + }, [viewportRef]); + + const scrollToBottom = useCallback((smooth = false) => { + viewportRef.current?.scrollToBottom(smooth ? "smooth" : "auto"); + }, [viewportRef]); + + const syncScrollToBottomState = useCallback(() => { + const atBottom = isNearBottom(); + const hasBufferedNewer = hasMoreNewerRef.current || unreadBelowIdsRef.current.size > 0; + + if (atBottom && !hasMoreNewerRef.current) { + setShowScrollToBottom(false); + return true; + } + + setShowScrollToBottom(!atBottom || hasBufferedNewer || historyNavigationMode); + return atBottom; + }, [hasMoreNewerRef, historyNavigationMode, isNearBottom, unreadBelowIdsRef]); + + const openDateNavigator = useCallback((dateValue?: string | null) => { + setSelectedHistoryDate(dateValue || floatingDateValue || formatDateInputValue(new Date())); + setShowFloatingDate(true); + setIsDateNavigatorOpen(true); + }, [floatingDateValue]); + + /** Update floating date from scroll position – call inside onScroll handler */ + const updateFloatingDate = useCallback((viewport: HTMLElement) => { + const messageEls = viewport.querySelectorAll("[data-message-date]"); + const viewportTop = viewport.getBoundingClientRect().top; + let topDate: string | null = null; + let topDateVal: string | null = null; + + for (let i = 0; i < messageEls.length; i += 1) { + const el = messageEls[i]; + if (el.getBoundingClientRect().bottom > viewportTop + 8) { + const iso = el.getAttribute("data-message-date"); + if (iso) { + const d = new Date(iso); + if (!Number.isNaN(d.getTime())) { + topDate = formatDayDivider(d); + topDateVal = formatDateInputValue(d); + } + } + break; + } + } + + if (topDate) { + setFloatingDate(topDate); + setFloatingDateValue(topDateVal); + setShowFloatingDate(true); + + if (floatingDateTimeoutRef.current) clearTimeout(floatingDateTimeoutRef.current); + floatingDateTimeoutRef.current = setTimeout(() => setShowFloatingDate(false), 1100); + return; + } + + setFloatingDate(null); + setFloatingDateValue(null); + setShowFloatingDate(false); + }, []); + + // Expose updateFloatingDate as a stable ref so the scroll handler can call it + const updateFloatingDateRef = useRef(updateFloatingDate); + useEffect(() => { updateFloatingDateRef.current = updateFloatingDate; }, [updateFloatingDate]); + + // Attach scroll handler after the message list actually exists in the DOM. + // `ref.current` changes do not trigger effects, so we re-run when messageCount changes. + useEffect(() => { + const c = viewportRef.current; + if (!c?.containerRef?.current) return; + const viewport = c.containerRef.current; + + const onScroll = () => updateFloatingDateRef.current(viewport); + const frameId = window.requestAnimationFrame(onScroll); + + viewport.addEventListener("scroll", onScroll, { passive: true }); + return () => { + window.cancelAnimationFrame(frameId); + viewport.removeEventListener("scroll", onScroll); + }; + }, [chatId, messageCount, viewportRef]); + + return { + showScrollToBottom, + setShowScrollToBottom, + floatingDate, + floatingDateValue, + showFloatingDate, + isDateNavigatorOpen, + setIsDateNavigatorOpen, + selectedHistoryDate, + setSelectedHistoryDate, + isNearBottom, + scrollToBottom, + syncScrollToBottomState, + openDateNavigator, + }; +} diff --git a/frontend/src/hooks/useMarkRead.ts b/frontend/src/hooks/useMarkRead.ts new file mode 100644 index 00000000..bd2ff8cb --- /dev/null +++ b/frontend/src/hooks/useMarkRead.ts @@ -0,0 +1,124 @@ +import { useCallback, useEffect, useRef } from "react"; +import { apiClient } from "@/lib/api"; + +const MARK_READ_DEBOUNCE_MS = 300; + +interface UseMarkReadOptions { + chatId: number; + /** Callback to update the chat state after a successful read acknowledgment */ + onReadAcknowledged?: (confirmedId: number) => void; +} + +/** + * Debounced mark-as-read logic. + * + * Returns helpers to schedule mark-read events, query the last confirmed + * read position, and clean-up resources. + */ +export function useMarkRead({ chatId, onReadAcknowledged }: UseMarkReadOptions) { + const timerRef = useRef(null); + const inFlightRef = useRef(false); + const queuedIdRef = useRef(null); + const lastConfirmedRef = useRef(0); + const onReadAcknowledgedRef = useRef(onReadAcknowledged); + + useEffect(() => { + onReadAcknowledgedRef.current = onReadAcknowledged; + }, [onReadAcknowledged]); + + // Reset all state when chatId changes + useEffect(() => { + if (timerRef.current) { + clearTimeout(timerRef.current); + timerRef.current = null; + } + inFlightRef.current = false; + queuedIdRef.current = null; + lastConfirmedRef.current = 0; + }, [chatId]); + + // Cleanup on unmount + useEffect(() => { + return () => { + if (timerRef.current) { + clearTimeout(timerRef.current); + timerRef.current = null; + } + }; + }, []); + + const flush = useCallback(async () => { + if (!chatId || Number.isNaN(chatId) || inFlightRef.current) return; + + const targetId = queuedIdRef.current; + if (typeof targetId !== "number") return; + if (targetId <= lastConfirmedRef.current) { + queuedIdRef.current = null; + return; + } + + queuedIdRef.current = null; + inFlightRef.current = true; + + try { + await apiClient.markChatAsRead(chatId, targetId); + lastConfirmedRef.current = Math.max(lastConfirmedRef.current, targetId); + onReadAcknowledgedRef.current?.(targetId); + } catch (error) { + console.error("Ошибка отложенной отметки прочтения:", error); + // Re-queue the failed id so the next flush picks it up + queuedIdRef.current = Math.max(queuedIdRef.current ?? 0, targetId); + } finally { + inFlightRef.current = false; + + // If another id was queued while we were in-flight, schedule another flush + if ( + queuedIdRef.current !== null && + queuedIdRef.current > lastConfirmedRef.current && + !timerRef.current + ) { + timerRef.current = setTimeout(() => { + timerRef.current = null; + void flush(); + }, MARK_READ_DEBOUNCE_MS); + } + } + }, [chatId]); + + /** + * Schedule a mark-read for the given message id. + * Debounces calls so multiple rapid updates collapse into one. + */ + const scheduleMarkRead = useCallback( + (messageId?: number | null) => { + if (!chatId || typeof messageId !== "number") return; + + const nextTarget = Math.max(queuedIdRef.current ?? 0, messageId); + if (nextTarget <= lastConfirmedRef.current) return; + + queuedIdRef.current = nextTarget; + + if (inFlightRef.current) return; + + if (timerRef.current) { + clearTimeout(timerRef.current); + } + + timerRef.current = setTimeout(() => { + timerRef.current = null; + void flush(); + }, MARK_READ_DEBOUNCE_MS); + }, + [chatId, flush], + ); + + /** Sync the confirmed watermark from external sources (e.g. chat.last_read_message_id). */ + const syncConfirmed = useCallback((id: number) => { + lastConfirmedRef.current = Math.max(lastConfirmedRef.current, id); + }, []); + + /** Read-only accessor – useful for comparisons without triggering re-renders. */ + const getLastConfirmed = useCallback(() => lastConfirmedRef.current, []); + + return { scheduleMarkRead, syncConfirmed, getLastConfirmed } as const; +} From 111ddb2bfd4f6b87c1e24c887fba8520259640ba Mon Sep 17 00:00:00 2001 From: lizerk Date: Fri, 3 Apr 2026 19:53:31 +0300 Subject: [PATCH 15/19] Refactor modals and add chat message search --- backend/communications/api/viewsets.py | 107 ++++++ frontend/next-env.d.ts | 2 +- frontend/src/app/equipment/page.tsx | 60 +-- frontend/src/app/messages/[chatId]/page.tsx | 228 ++++++++++- frontend/src/app/messages/page.tsx | 54 +-- frontend/src/app/page.tsx | 359 ++++++++---------- frontend/src/app/procurement/page.tsx | 26 +- frontend/src/app/requests/page.tsx | 31 +- frontend/src/components/AvatarCropper.tsx | 7 +- frontend/src/components/CalendarModal.tsx | 65 ++-- .../components/CalendarParticipantsModal.tsx | 13 +- .../components/CalendarSubscriptionsModal.tsx | 15 +- frontend/src/components/EventModal.tsx | 75 ++-- frontend/src/components/NewChatModal.tsx | 298 +++++++-------- .../src/components/ViewDayEventsModal.tsx | 56 ++- .../src/components/ViewEventDetailsModal.tsx | 85 ++--- .../DocumentAcknowledgementsReport.tsx | 25 +- .../components/documents/DocumentPreview.tsx | 7 +- .../related/RelatedDocumentsList.tsx | 46 +-- .../messages/AddChatMemberModal.tsx | 140 ++++--- .../components/messages/ChatDialogHeader.tsx | 145 ++++--- .../messages/ChatMediaPreviewModal.tsx | 2 +- .../components/messages/ChatMessageItem.tsx | 39 +- .../components/messages/ChatSearchPanel.tsx | 189 +++++++++ .../src/components/messages/EditChatModal.tsx | 57 ++- .../procurement/ProcurementSuppliersPanel.tsx | 21 +- frontend/src/components/ui/Modal.tsx | 95 ++--- .../components/users/EditUserProfileModal.tsx | 71 ++-- .../components/users/EmployeeActionModal.tsx | 167 ++++---- frontend/src/hooks/useChatMessages.ts | 41 ++ frontend/src/lib/api/messages.ts | 8 + frontend/src/types/api.ts | 18 + 32 files changed, 1510 insertions(+), 1042 deletions(-) create mode 100644 frontend/src/components/messages/ChatSearchPanel.tsx diff --git a/backend/communications/api/viewsets.py b/backend/communications/api/viewsets.py index 243eb75f..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 для управления чатами @@ -855,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): """ diff --git a/frontend/next-env.d.ts b/frontend/next-env.d.ts index 9edff1c7..c4b7818f 100644 --- a/frontend/next-env.d.ts +++ b/frontend/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -import "./.next/types/routes.d.ts"; +import "./.next/dev/types/routes.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. 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/[chatId]/page.tsx b/frontend/src/app/messages/[chatId]/page.tsx index 36791bcc..74dd5cb4 100644 --- a/frontend/src/app/messages/[chatId]/page.tsx +++ b/frontend/src/app/messages/[chatId]/page.tsx @@ -7,12 +7,13 @@ 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 { 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"; @@ -69,6 +70,8 @@ export default function MessageDialogPage() { 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) @@ -141,6 +144,16 @@ export default function MessageDialogPage() { const [isPinned, setIsPinned] = useState(false); const [notificationsEnabled, setNotificationsEnabled] = useState(true); 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); // Sync settings from chat useEffect(() => { @@ -150,6 +163,17 @@ export default function MessageDialogPage() { } }, [cm.chat]); + useEffect(() => { + if (!isSearchOpen) { + return; + } + + requestAnimationFrame(() => { + searchInputRef.current?.focus(); + searchInputRef.current?.select(); + }); + }, [isSearchOpen]); + /* ── membership & permissions ── */ const myMembership = useMemo(() => { if (!cm.chat?.memberships || !user?.id) return null; @@ -313,6 +337,7 @@ export default function MessageDialogPage() { /* ── cleanup timers ── */ useEffect(() => () => { if (typingTimeoutRef.current) clearTimeout(typingTimeoutRef.current); + if (highlightTimerRef.current) clearTimeout(highlightTimerRef.current); }, []); useLayoutEffect(() => { @@ -602,6 +627,171 @@ export default function MessageDialogPage() { } catch (e) { console.error("Ошибка переключения уведомлений:", e); } }; + 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 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 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(() => { @@ -628,11 +818,12 @@ export default function MessageDialogPage() { setReactionPickerForMessageId(null); setShowComposerEmojiPicker(false); scroll.setIsDateNavigatorOpen(false); + if (isSearchOpen) closeSearchPanel(); } }; window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); - }, [scroll]); + }, [closeSearchPanel, isSearchOpen, scroll]); // Mobile keyboard viewport adjustment useEffect(() => { @@ -655,7 +846,14 @@ export default function MessageDialogPage() { if (!c?.containerRef?.current) return; const v = c.containerRef.current; const t = e.target as HTMLElement | null; - if (!t || v.contains(t) || t.closest("textarea,input,button,a,video,audio,[contenteditable='true']")) return; + 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(); v.scrollTop += e.deltaY; }; @@ -691,6 +889,29 @@ export default function MessageDialogPage() { notificationsEnabled={notificationsEnabled} onTogglePin={handleTogglePin} onToggleNotifications={handleToggleNotifications} + onOpenSearch={openSearchPanel} + /> + + { + void handleSelectSearchResult(index); + }} + onLoadMore={handleLoadMoreSearchResults} + onSubmitSelection={handleSubmitSelectedSearchResult} />
@@ -756,6 +977,7 @@ export default function MessageDialogPage() { message={message} currentUserId={user?.id} repliedMessage={repliedMessage} + isHighlighted={highlightedMessageId === message.id} isActionsOpen={expandedReplyActionForId === message.id} canManage={!message.is_optimistic && canManageMessage(message)} canReply={!message.is_deleted && !message.is_optimistic} diff --git a/frontend/src/app/messages/page.tsx b/frontend/src/app/messages/page.tsx index 93ab0132..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"; @@ -661,19 +662,25 @@ export default function MessagesPage() { )} {/* Модальное окно создания чата */} - {showCreateModal && ( -
-
-
-

Создать чат

- + setShowCreateModal(false)} title="Создать чат" size="sm" closeOnEsc={!creating} footer={ +
+ +
- + }>
{/* Тип чата */}
@@ -799,29 +806,8 @@ export default function MessagesPage() { )}
- - {/* Кнопки */} -
- - -
-
-
- )} + ); } diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index 6c6b8b16..7b345da6 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -2,6 +2,7 @@ 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"; @@ -683,152 +684,180 @@ export default function Home() { )}
- {createPostOpen ? ( -
- + +
+ } + > +
+ {createError ? ( +
{createError}
+ ) : null} -
-
-

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

-