Add websocket read receipts and AsyncAPI docs

This commit is contained in:
lizerk 2026-04-03 09:55:10 +03:00
parent 0a71d6a3c9
commit d1aea28be9
12 changed files with 901 additions and 28 deletions

View File

@ -8,6 +8,4 @@ class ApiConfig(AppConfig):
def ready(self):
"""Инициализация при загрузке приложения."""
# Патч для django-scheduler теперь применяется в
# scheduling.apps.SchedulingConfig
pass

61
backend/api/docs_views.py Normal file
View File

@ -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

View File

@ -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/",

View File

@ -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 уведомление

View File

@ -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]
)

View File

@ -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,

View File

@ -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=<jwt_access_token>
- или заголовок Authorization: Bearer <jwt_access_token>
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

View File

@ -0,0 +1,137 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>EUSRR WebSocket AsyncAPI</title>
<style>
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
background: #f6f8fb;
color: #1f2937;
}
.page {
max-width: 1100px;
margin: 0 auto;
padding: 32px 24px 48px;
}
.hero {
background: linear-gradient(135deg, #ffffff, #eef6ff);
border: 1px solid #dbe5f1;
border-radius: 20px;
padding: 28px;
box-shadow: 0 10px 30px rgba(15, 23, 42, 0.06);
}
h1, h2 {
margin: 0 0 12px;
}
p {
line-height: 1.6;
}
.links {
display: flex;
flex-wrap: wrap;
gap: 12px;
margin: 20px 0 0;
}
.links a {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 10px 14px;
border-radius: 10px;
text-decoration: none;
border: 1px solid #c7d7ea;
background: #fff;
color: #0f4c81;
font-weight: 600;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 16px;
margin-top: 24px;
}
.card {
background: #fff;
border: 1px solid #dbe5f1;
border-radius: 16px;
padding: 20px;
}
code, pre {
font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace;
}
code {
background: #eef4fb;
border-radius: 6px;
padding: 2px 6px;
}
pre {
margin: 0;
white-space: pre-wrap;
word-break: break-word;
background: #0f172a;
color: #dbeafe;
border-radius: 16px;
padding: 20px;
overflow-x: auto;
line-height: 1.45;
}
.section {
margin-top: 28px;
}
</style>
</head>
<body>
<div class="page">
<section class="hero">
<h1>WebSocket AsyncAPI</h1>
<p>
Отдельная документация realtime-контура EUSRR. HTTP API продолжает
жить в OpenAPI, а WebSocket-контур документируется отдельно через
AsyncAPI, без смешивания двух протоколов в одной схеме.
</p>
<div class="links">
<a href="{{ asyncapi_schema_url }}">Скачать AsyncAPI YAML</a>
<a href="{{ swagger_url }}">HTTP Swagger</a>
<a href="{{ redoc_url }}">HTTP ReDoc</a>
<a href="{{ openapi_schema_url }}">HTTP OpenAPI schema</a>
</div>
</section>
<section class="grid section">
<article class="card">
<h2>Точка подключения</h2>
<p><code>{{ ws_endpoint_url }}</code></p>
<p>
Аутентификация поддерживает либо query-параметр
<code>?token=&lt;jwt_access_token&gt;</code>, либо заголовок
<code>Authorization: Bearer &lt;jwt_access_token&gt;</code>.
</p>
</article>
<article class="card">
<h2>Что описано</h2>
<p>
В схеме отражены клиентские action-сообщения и серверные
realtime-события для чатов, уведомлений, typing, реакций,
read-sync и procurement updates.
</p>
</article>
<article class="card">
<h2>Почему отдельно</h2>
<p>
Swagger и ReDoc используют OpenAPI-схему HTTP API. Для
WebSocket-протокола отдельный AsyncAPI-документ чище,
понятнее и лучше масштабируется.
</p>
</article>
</section>
<section class="section">
<h2>Содержимое AsyncAPI</h2>
<pre>{{ schema_text }}</pre>
</section>
</div>
</body>
</html>

View File

@ -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()

View File

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

View File

@ -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 (
<span
className={`inline-flex items-center ${isRead ? "text-white" : "text-sky-100"}`}
title={isRead ? "Прочитано" : "Отправлено"}
aria-label={isRead ? "Прочитано" : "Отправлено"}
>
<Icon size={13} strokeWidth={2.2} />
</span>
);
}
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 (
<div data-message-id={message.id} data-message-date={currentDate?.toISOString() || ""} className="mb-3 last:mb-0">
@ -267,7 +282,10 @@ export default function ChatMessageItem({
</div>
) : null}
<p className={`mt-1 text-right text-[11px] ${isMine ? "text-sky-100" : "text-gray-400"}`}>{messageTime(message)}</p>
<div className={`mt-1 flex items-center ${isMine ? "justify-end gap-1.5" : "justify-end"}`}>
<p className={`text-right text-[11px] ${isMine ? "text-sky-100" : "text-gray-400"}`}>{messageTime(message)}</p>
{isMine ? <MessageDeliveryStatus isRead={isRead} /> : null}
</div>
{Object.keys(message.reactions_summary || {}).length > 0 ? (
<div className="mt-2 flex flex-wrap gap-1">

View File

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