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; }