fix: Исправлена система автоотметки прочитанных сообщений

- Удалено поле last_read_at из ChatReadState, используется только last_read_message FK
- Исправлена логика автоотметки: отмечается последнее ЗАГРУЖЕННОЕ сообщение, а не последнее в чате
- Убран вызов chat.mark_read() при WebSocket open_chat (отмечал последнее в чате вместо загруженного)
- Добавлен signal для мониторинга изменений ChatReadState
- Исправлена логика скролла: убран автоскролл при дозагрузке вниз
- Добавлена проверка разрывов при WebSocket сообщениях (предотвращает пропуск сообщений)
- Реализована автоотметка WebSocket сообщений (только если пользователь внизу)
- Асимметричная загрузка: 24 контекста + 6 новых сообщений
- Добавлен ScrollableMessageList компонент для корректной работы скролла
- Миграции: 0028 (удаление last_read_at), 0029 (индекс на last_read_message)
This commit is contained in:
igor_nadein 2026-02-25 20:29:00 +03:00
parent eb928f8fb1
commit bc20d7e61e
15 changed files with 2008 additions and 379 deletions

View File

@ -42,6 +42,7 @@ class ChatListSerializer(serializers.ModelSerializer):
participant_names = serializers.SerializerMethodField()
is_pinned = serializers.SerializerMethodField()
notifications_enabled = serializers.SerializerMethodField()
last_read_message_id = serializers.SerializerMethodField()
class Meta:
model = Chat
@ -49,7 +50,7 @@ class ChatListSerializer(serializers.ModelSerializer):
'id', 'type', 'name', 'description', 'avatar',
'created_at', 'is_main', 'department',
'last_message', 'unread_count', 'participant_names',
'is_pinned', 'notifications_enabled'
'is_pinned', 'notifications_enabled', 'last_read_message_id'
]
def get_last_message(self, obj):
@ -85,6 +86,18 @@ class ChatListSerializer(serializers.ModelSerializer):
settings = obj.user_settings.filter(user=request.user).first()
return settings.notifications_enabled if settings else True
return True
def get_last_read_message_id(self, obj):
"""ID последнего прочитанного сообщения для текущего пользователя"""
from communications.models import ChatReadState
request = self.context.get('request')
if request and request.user.is_authenticated:
read_state = ChatReadState.objects.filter(
chat=obj,
user=request.user
).only('last_read_message_id').first()
return read_state.last_read_message_id if read_state else None
return None
class ChatDetailSerializer(serializers.ModelSerializer):
@ -97,6 +110,7 @@ class ChatDetailSerializer(serializers.ModelSerializer):
participant_details = serializers.SerializerMethodField()
memberships = ChatMembershipSerializer(many=True, read_only=True, source='chatmembership_set')
user_settings = serializers.SerializerMethodField()
last_read_message_id = serializers.SerializerMethodField()
class Meta:
model = Chat
@ -105,7 +119,7 @@ class ChatDetailSerializer(serializers.ModelSerializer):
'created_by', 'created_at', 'is_main',
'include_all_employees', 'department',
'participants', 'participant_details',
'memberships', 'user_settings'
'memberships', 'user_settings', 'last_read_message_id'
]
read_only_fields = ['created_at', 'created_by']
@ -126,6 +140,18 @@ class ChatDetailSerializer(serializers.ModelSerializer):
if settings:
return ChatUserSettingsSerializer(settings).data
return {'is_pinned': False, 'notifications_enabled': True}
def get_last_read_message_id(self, obj):
"""ID последнего прочитанного сообщения для текущего пользователя"""
from communications.models import ChatReadState
request = self.context.get('request')
if request and request.user.is_authenticated:
read_state = ChatReadState.objects.filter(
chat=obj,
user=request.user
).only('last_read_message_id').first()
return read_state.last_read_message_id if read_state else None
return None
class MessageAttachmentSerializer(serializers.ModelSerializer):

View File

@ -17,7 +17,8 @@ import logging
from datetime import datetime, timezone as dt_tz
from django.contrib.auth import get_user_model
from django.db import transaction
from django.db.models import Q, Count, Prefetch, Exists, OuterRef
from django.db.models import Q, Count, Prefetch, Exists, OuterRef, Subquery
from django.db.models.functions import Coalesce
from django.utils import timezone
from rest_framework import viewsets, status
from rest_framework.decorators import action
@ -58,9 +59,14 @@ class ChatViewSet(viewsets.ModelViewSet):
Actions:
- pin: POST /api/v1/communications/chats/{id}/pin/ - закрепить/открепить
- notifications: POST /api/v1/communications/chats/{id}/notifications/ - вкл/выкл уведомления
- messages: GET /api/v1/communications/chats/{id}/messages/ - список сообщений
- messages_around: GET /api/v1/communications/chats/{id}/messages_around/ - сообщения вокруг
- mark_read: POST /api/v1/communications/chats/{id}/mark_read/ - пометить как прочитанное
- messages: GET /api/v1/communications/chats/{id}/messages/ - список сообщений (автоотметка)
- messages_around: GET /api/v1/communications/chats/{id}/messages_around/ - сообщения вокруг (автоотметка)
- mark_read: POST /api/v1/communications/chats/{id}/mark_read/ - [DEPRECATED] используйте GET /messages/
Автоматическая отметка прочитанных (Telegram-style):
При любой загрузке сообщений (messages, messages_around) последнее загруженное
сообщение автоматически отмечается как прочитанное через last_read_message_id.
Ручной вызов /mark-read/ больше не требуется.
"""
permission_classes = [IsAuthenticated]
@ -74,16 +80,17 @@ class ChatViewSet(viewsets.ModelViewSet):
"""Чаты доступные пользователю с аннотацией непрочитанных"""
user = self.request.user
# Подзапрос для последнего прочитанного времени
# Подзапрос для last_read_message_id (Telegram-style)
read_state_subq = ChatReadState.objects.filter(
chat=OuterRef('pk'),
user=user
).values('last_read_at')[:1]
).values('last_read_message_id')[:1]
# Подзапрос для количества непрочитанных
# Считаем сообщения с ID больше последнего прочитанного
unread_count_subq = Message.objects.filter(
chat=OuterRef('pk'),
created_at__gt=read_state_subq,
id__gt=Coalesce(Subquery(read_state_subq), 0),
is_deleted=False
).exclude(author=user).values('chat').annotate(
count=Count('id')
@ -157,6 +164,52 @@ class ChatViewSet(viewsets.ModelViewSet):
'notifications_enabled': settings.notifications_enabled
})
def _auto_mark_read(self, chat, user, messages):
"""
Автоматически отмечает последнее загруженное сообщение как прочитанное.
Pragmatic подход: "загрузил = прочитал" с ограничением количества новых сообщений.
Защита от откатов: обновляет только если новое сообщение НОВЕЕ текущего.
"""
if not messages:
return
last_message = messages[-1]
read_state, created = ChatReadState.objects.get_or_create(
chat=chat,
user=user,
defaults={'last_read_message': last_message}
)
if created:
logger.info(f"[auto_mark_read] Created: user={user.id}, chat={chat.id}, msg={last_message.id}")
self._send_marked_read_event(user.id, chat.id, last_message.id)
return
# Защита от откатов: только если НОВЕЕ
if read_state.last_read_message_id and last_message.id <= read_state.last_read_message_id:
logger.debug(f"[auto_mark_read] Skip: {last_message.id} <= {read_state.last_read_message_id}")
return
read_state.last_read_message = last_message
read_state.save(update_fields=['last_read_message', 'updated_at'])
logger.info(f"[auto_mark_read] Updated: user={user.id}, chat={chat.id}, msg={last_message.id}")
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 событие для синхронизации между вкладками"""
channel_layer = get_channel_layer()
async_to_sync(channel_layer.group_send)(
f'user_{user_id}',
{
'type': 'chat_marked_read',
'chat_id': chat_id,
'last_read_message_id': message_id
}
)
@action(detail=True, methods=['get'])
def messages(self, request, pk=None):
"""Загрузка сообщений чата (пагинация по времени)"""
@ -222,6 +275,11 @@ class ChatViewSet(viewsets.ModelViewSet):
if not (after_id or after_ts):
messages.reverse()
# Автоотметка при загрузке НОВЫХ сообщений (after_id/after_ts)
# При scroll вверх (before_id) - не отмечаем
if (after_id or after_ts) and messages:
self._auto_mark_read(chat, request.user, messages)
return Response({
'messages': [serialize_message(m) for m in messages],
'has_more': has_more
@ -229,7 +287,16 @@ class ChatViewSet(viewsets.ModelViewSet):
@action(detail=True, methods=['get'], url_path='messages-around')
def messages_around(self, request, pk=None):
"""Загрузка сообщений вокруг указанного"""
"""
Загрузка сообщений вокруг указанного (last_read_message_id).
Асимметричная загрузка для браузерных приложений:
- 24 сообщения ДО (контекст прочитанных)
- 6 сообщений ПОСЛЕ (новые непрочитанные)
Автоматическая отметка последнего загруженного как прочитанного.
Погрешность: макс 1-2 невидимых сообщения (приемлемо для веб).
"""
chat = self.get_object()
if not user_can_access_chat(chat, request.user):
@ -239,8 +306,16 @@ class ChatViewSet(viewsets.ModelViewSet):
)
around_id = request.query_params.get('around_id')
limit = min(int(request.query_params.get('limit', 30)), 100)
half = limit // 2
# Асимметричные лимиты (24 + 6 = 30 сообщений)
before_limit = int(request.query_params.get('before_limit', 24))
after_limit = int(request.query_params.get('after_limit', 6))
# Для обратной совместимости с limit параметром
if 'limit' in request.query_params and 'before_limit' not in request.query_params:
total_limit = min(int(request.query_params.get('limit', 30)), 100)
before_limit = int(total_limit * 0.8) # 80% на контекст
after_limit = total_limit - before_limit # 20% на новые
queryset = chat.messages.filter(is_deleted=False).select_related(
'author', 'reply_to', 'reply_to__author', 'poll'
@ -259,8 +334,15 @@ class ChatViewSet(viewsets.ModelViewSet):
# Если все еще нет around_id - возвращаем последние сообщения
if not around_id:
messages = list(queryset.order_by('-created_at')[:limit])
fallback_limit = before_limit + after_limit
messages = list(queryset.order_by('-created_at')[:fallback_limit])
messages.reverse()
# Автоотметка: последнее ЗАГРУЖЕННОЕ становится last_read
# Frontend при следующей загрузке запросит вокруг ЭТОГО сообщения
if messages:
self._auto_mark_read(chat, request.user, messages)
return Response({
'messages': [serialize_message(m) for m in messages],
'messages_count': len(messages),
@ -304,8 +386,15 @@ class ChatViewSet(viewsets.ModelViewSet):
if not anchor_msg:
# Если не найдено - возвращаем последние сообщения
messages = list(queryset.order_by('-created_at')[:limit])
fallback_limit = before_limit + after_limit
messages = list(queryset.order_by('-created_at')[:fallback_limit])
messages.reverse()
# Автоотметка: последнее ЗАГРУЖЕННОЕ становится last_read
# Frontend при следующей загрузке запросит вокруг ЭТОГО сообщения
if messages:
self._auto_mark_read(chat, request.user, messages)
return Response({
'messages': [serialize_message(m) for m in messages],
'messages_count': len(messages),
@ -313,127 +402,111 @@ class ChatViewSet(viewsets.ModelViewSet):
'anchor_index': 0
})
# Загружаем половину до и половину после
# Асимметричная загрузка: больше контекста, меньше новых
before = list(queryset.filter(
created_at__lt=anchor_msg.created_at
).order_by('-created_at')[:half])
).order_by('-created_at')[:before_limit])
before.reverse()
after = list(queryset.filter(
created_at__gte=anchor_msg.created_at
).order_by('created_at')[:half+1])
).order_by('created_at')[:after_limit + 1]) # +1 для включения anchor
messages = before + after
anchor_index = len(before)
# Автоотметка: последнее ЗАГРУЖЕННОЕ становится last_read (24 контекста + 6 новых)
# Frontend при следующей загрузке запросит вокруг последнего из ЭТИХ сообщений
# НЕ отмечается последнее в чате, а последнее из того что мы загрузили!
if messages:
self._auto_mark_read(chat, request.user, messages)
return Response({
'messages': [serialize_message(m) for m in messages],
'messages_count': len(messages),
'anchor_id': anchor_msg.id, # Возвращаем реальный message_id
'anchor_id': anchor_msg.id,
'anchor_index': anchor_index,
'has_more_before': len(before) >= half,
'has_more_after': len(after) > half
'has_more_before': len(before) >= before_limit,
'has_more_after': len(after) > after_limit
})
@action(detail=True, methods=['post'], url_path='mark-read')
def mark_read(self, request, pk=None):
"""
Пометить чат как прочитанный
Ручная отметка прочитанных сообщений (опционально).
Обычно используется автоматическая отметка при GET /messages-around/.
Этот endpoint полезен для точной отметки через IntersectionObserver.
Параметры:
- upto_ts: timestamp последнего прочитанного сообщения (опционально)
- message_id: ID последнего прочитанного сообщения (опционально)
- message_id: ID последнего прочитанного сообщения (рекомендуется)
- upto_ts: timestamp (legacy, используйте message_id)
"""
chat = self.get_object()
logger.info(f"[mark_read] User {request.user.id} marking chat {chat.id} as read")
logger.info(f"[mark_read] Request data: {request.data}")
logger.info(f"[mark_read] Manual mark by user {request.user.id} for chat {chat.id}")
read_state, created = ChatReadState.objects.get_or_create(
chat=chat,
user=request.user
)
logger.info(f"[mark_read] ReadState {'created' if created else 'found'}: last_read_at={read_state.last_read_at}, last_read_message_id={read_state.last_read_message_id}")
# Обновляем timestamp
upto_ts = request.data.get('upto_ts')
new_timestamp = None
if upto_ts:
try:
# Конвертируем timestamp (может быть в миллисекундах)
ts_value = float(upto_ts)
if ts_value > 10000000000: # Timestamp в миллисекундах
ts_value = ts_value / 1000
new_timestamp = datetime.fromtimestamp(ts_value, tz=dt_tz.utc)
logger.info(f"[mark_read] Parsed timestamp: {new_timestamp}")
except (ValueError, OSError) as e:
new_timestamp = timezone.now()
logger.warning(f"[mark_read] Invalid timestamp, using now: {e}")
else:
new_timestamp = timezone.now()
logger.info(f"[mark_read] No timestamp provided, using now: {new_timestamp}")
# Проверка: не откатываем назад
if read_state.last_read_at and new_timestamp <= read_state.last_read_at:
logger.warning(f"[mark_read] SKIPPING - new timestamp {new_timestamp} is not newer than current {read_state.last_read_at}")
return Response({
'ok': True,
'last_read_at': read_state.last_read_at.isoformat() if read_state.last_read_at else None,
'last_read_message_id': read_state.last_read_message_id,
'skipped': True,
'reason': 'timestamp_not_newer'
})
# Обновляем только если новее
read_state.last_read_at = new_timestamp
logger.info(f"[mark_read] Updated last_read_at to: {read_state.last_read_at}")
# Обновляем last_read_message если передан message_id
# Основная логика: поддерживаем message_id
message_id = request.data.get('message_id')
if message_id:
try:
message = chat.messages.get(pk=int(message_id))
read_state.last_read_message = message
logger.info(f"[mark_read] Set last_read_message from request: {message.id}")
message = chat.messages.get(pk=int(message_id), is_deleted=False)
# Используем новую логику через _auto_mark_read
self._auto_mark_read(chat, request.user, [message])
return Response({
'ok': True,
'last_read_message_id': message.id
})
except (Message.DoesNotExist, ValueError) as e:
logger.warning(f"[mark_read] Could not find message {message_id}: {e}")
else:
# Если не передан явно, находим последнее сообщение до указанного времени
if read_state.last_read_at:
logger.error(f"[mark_read] Invalid message_id {message_id}: {e}")
return Response(
{'error': f'Message {message_id} not found'},
status=status.HTTP_404_NOT_FOUND
)
# Fallback: если передан upto_ts (старая логика)
upto_ts = request.data.get('upto_ts')
if upto_ts:
try:
ts_value = float(upto_ts)
if ts_value > 10000000000:
ts_value = ts_value / 1000
timestamp = datetime.fromtimestamp(ts_value, tz=dt_tz.utc)
# Находим последнее сообщение до этого времени
last_msg = chat.messages.filter(
created_at__lte=read_state.last_read_at,
created_at__lte=timestamp,
is_deleted=False
).order_by('-created_at').first()
if last_msg:
read_state.last_read_message = last_msg
logger.info(f"[mark_read] Auto-detected last_read_message: {last_msg.id}")
self._auto_mark_read(chat, request.user, [last_msg])
return Response({
'ok': True,
'last_read_message_id': last_msg.id,
'deprecated': True,
'message': 'Using upto_ts is deprecated. Use message_id instead.'
})
else:
logger.warning(f"[mark_read] No messages found before {read_state.last_read_at}")
return Response(
{'error': 'No messages found before specified timestamp'},
status=status.HTTP_404_NOT_FOUND
)
except (ValueError, OSError) as e:
logger.error(f"[mark_read] Invalid timestamp: {e}")
return Response(
{'error': 'Invalid timestamp format'},
status=status.HTTP_400_BAD_REQUEST
)
read_state.save()
logger.info(f"[mark_read] SAVED ReadState: chat={chat.id}, user={request.user.id}, last_read_at={read_state.last_read_at}, last_read_message_id={read_state.last_read_message_id}")
# Отправляем WebSocket событие для синхронизации между вкладками
channel_layer = get_channel_layer()
async_to_sync(channel_layer.group_send)(
f'user_{request.user.id}',
{
'type': 'chat_marked_read',
'chat_id': chat.id,
'last_read_at': read_state.last_read_at.isoformat() if read_state.last_read_at else None,
'last_read_message_id': read_state.last_read_message_id,
}
# Ни message_id ни upto_ts не переданы
return Response(
{'error': 'Either message_id or upto_ts required'},
status=status.HTTP_400_BAD_REQUEST
)
logger.info(f"[mark_read] Sent WebSocket event: chat_marked_read for chat {chat.id}")
return Response({
'ok': True,
'last_read_at': read_state.last_read_at.isoformat() if read_state.last_read_at else None,
'last_read_message_id': read_state.last_read_message_id
})
class MessageViewSet(viewsets.ModelViewSet):

View File

@ -9,16 +9,19 @@ from .models import Chat, ChatReadState, Message
def chat_unread_total(request):
"""
Считает общее количество непрочитанных сообщений пользователя.
Использует last_read_message_id (Telegram-style) вместо last_read_at.
"""
if not request.user.is_authenticated:
return {"chat_unread_total": 0}
user = request.user
default_dt = timezone.make_aware(datetime.datetime(1970, 1, 1))
# last_read_at для каждого чата текущего юзера
last_read_sq = ChatReadState.objects.filter(
# last_read_message_id для каждого чата текущего юзера
last_read_msg_sq = ChatReadState.objects.filter(
chat=models.OuterRef("pk"), user=user
).values("last_read_at")[:1]
).values("last_read_message_id")[:1]
# доступные юзеру чаты
deps = getattr(user, "departments", None)
@ -31,11 +34,11 @@ def chat_unread_total(request):
| Q(type="department", department__in=deps_qs)
)
.distinct()
.annotate(last_read_at=Coalesce(Subquery(last_read_sq), Value(default_dt)))
.annotate(last_read_msg_id=Coalesce(Subquery(last_read_msg_sq), Value(0)))
.annotate(
unread=Count(
"messages",
filter=Q(messages__created_at__gt=F("last_read_at"))
filter=Q(messages__id__gt=F("last_read_msg_id"))
& ~Q(messages__author=user),
distinct=True,
)

View File

@ -0,0 +1,31 @@
# Generated manually - Remove deprecated fields from ChatReadState
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('communications', '0027_remove_message_edit_history_and_more'),
]
operations = [
# Удаляем индекс, который использует last_read_at
migrations.RemoveIndex(
model_name='chatreadstate',
name='communicati_chat_id_edefdb_idx',
),
# Удаляем устаревшие поля
migrations.RemoveField(
model_name='chatreadstate',
name='last_read_at',
),
migrations.RemoveField(
model_name='chatreadstate',
name='unread_mentions_count',
),
migrations.RemoveField(
model_name='chatreadstate',
name='unread_thread_replies_count',
),
]

View File

@ -0,0 +1,25 @@
# Generated by Django 5.2.4 on 2026-02-25 13:58
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('communications', '0028_remove_last_read_at_and_unused_fields'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AlterField(
model_name='chatreadstate',
name='last_read_message',
field=models.ForeignKey(blank=True, help_text='Обновляется автоматически при загрузке сообщений (Telegram-style)', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='communications.message', verbose_name='Последнее прочитанное сообщение'),
),
migrations.AddIndex(
model_name='chatreadstate',
index=models.Index(fields=['last_read_message'], name='communicati_last_re_2f5f7e_idx'),
),
]

View File

@ -11,6 +11,12 @@ Employee = get_user_model()
class ChatReadState(models.Model):
"""
Состояние прочтения чата пользователем.
Использует Telegram-style подход: последнее прочитанное сообщение
отмечается автоматически при загрузке сообщений через GET запросы.
"""
chat = models.ForeignKey(
"Chat", on_delete=models.CASCADE, related_name="read_states"
)
@ -19,31 +25,17 @@ class ChatReadState(models.Model):
on_delete=models.CASCADE,
related_name="chat_read_states",
)
last_read_at = models.DateTimeField(null=True, blank=True)
updated_at = models.DateTimeField(auto_now=True)
# НОВЫЕ ПОЛЯ для расширенной функциональности
# Последнее прочитанное сообщение (явная связь)
# Последнее прочитанное сообщение (единственный источник истины)
last_read_message = models.ForeignKey(
'Message',
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name='+',
verbose_name="Последнее прочитанное сообщение"
)
# Упоминания
unread_mentions_count = models.IntegerField(
default=0,
verbose_name="Непрочитанных упоминаний"
)
# Треды
unread_thread_replies_count = models.IntegerField(
default=0,
verbose_name="Непрочитанных ответов в тредах"
verbose_name="Последнее прочитанное сообщение",
help_text="Обновляется автоматически при загрузке сообщений (Telegram-style)"
)
# Статус набора текста
@ -58,7 +50,6 @@ class ChatReadState(models.Model):
)
class Meta:
# заменяем устаревший unique_together на UniqueConstraint
constraints = [
models.UniqueConstraint(
fields=["chat", "user"],
@ -67,12 +58,13 @@ class ChatReadState(models.Model):
]
indexes = [
models.Index(fields=["chat", "user"]),
models.Index(fields=["chat", "last_read_at"]),
models.Index(fields=["chat", "user", "is_typing"]),
models.Index(fields=["last_read_message"]), # Для быстрого поиска по message
]
def __str__(self):
return f"read:{self.user_id}@{self.chat_id} → {self.last_read_at or '-'}"
msg_id = self.last_read_message_id if self.last_read_message_id else '-'
return f"read:{self.user_id}@{self.chat_id} → msg#{msg_id}"
class Chat(models.Model):
@ -217,36 +209,46 @@ class Chat(models.Model):
def mark_read(self, user):
"""
Помечает чат прочитанным «до последнего сообщения».
Сделано без update_or_create — устойчиво для SQLite.
[DEPRECATED] Помечает чат прочитанным до последнего сообщения.
ВНИМАНИЕ: Этот метод устарел. Используйте автоматическую отметку
через ChatViewSet._auto_mark_read() при загрузке сообщений.
"""
last = self.messages.order_by("-created_at").only("created_at").first()
ts = last.created_at if last else timezone.now()
# обновим, только если новое время больше
updated = ChatReadState.objects.filter(
chat=self, user=user, last_read_at__lt=ts
).update(last_read_at=ts)
if not updated:
try:
ChatReadState.objects.create(chat=self, user=user, last_read_at=ts)
except IntegrityError:
# гонка: запись уже есть — попробуем ещё раз обновить
ChatReadState.objects.filter(
chat=self, user=user, last_read_at__lt=ts
).update(last_read_at=ts)
last_message = self.messages.order_by("-created_at").first()
if not last_message:
return
# Обновляем, только если новое сообщение НОВЕЕ текущего
read_state, created = ChatReadState.objects.get_or_create(
chat=self,
user=user,
defaults={'last_read_message': last_message}
)
if not created:
if read_state.last_read_message_id:
if last_message.id <= read_state.last_read_message_id:
return # Не откатываем назад
read_state.last_read_message = last_message
read_state.save(update_fields=['last_read_message', 'updated_at'])
def unread_count_for(self, user):
"""
Количество непрочитанных (кроме своих).
Количество непрочитанных сообщений (кроме своих).
Считает сообщения после last_read_message.
"""
rs = (
ChatReadState.objects.filter(chat=self, user=user)
.only("last_read_at")
.only("last_read_message_id")
.first()
)
qs = self.messages.exclude(author=user)
if rs and rs.last_read_at:
qs = qs.filter(created_at__gt=rs.last_read_at)
if rs and rs.last_read_message_id:
# Считаем сообщения с ID больше последнего прочитанного
qs = qs.filter(id__gt=rs.last_read_message_id)
return qs.count()
@property

View File

@ -129,13 +129,9 @@ class ChatListView(LoginRequiredMixin, ListView):
.values("created_at")
.order_by("-created_at")[:1]
)
last_read_qs = ChatReadState.objects.filter(
last_read_msg_qs = ChatReadState.objects.filter(
chat=models.OuterRef("pk"), user=user
).values("last_read_at")[:1]
default_dt = timezone.make_aware(
datetime.datetime(1970, 1, 1), timezone.get_current_timezone()
).astimezone(dt_tz.utc)
).values("last_read_message_id")[:1]
# Получаем ID чатов, где пользователь является участником
# через membership
@ -166,17 +162,17 @@ class ChatListView(LoginRequiredMixin, ListView):
.select_related("department", "created_by", "blocked_by")
.annotate(last_msg_at=Subquery(last_qs))
.annotate(
last_read_at=Coalesce(
Subquery(last_read_qs),
Value(default_dt, output_field=models.DateTimeField()),
output_field=models.DateTimeField(),
last_read_msg_id=Coalesce(
Subquery(last_read_msg_qs),
Value(0, output_field=models.IntegerField()),
output_field=models.IntegerField(),
)
)
.annotate(
# считаем только чужие сообщения после last_read_at
# Считаем только чужие сообщения с ID больше last_read_message_id
unread_count=Count(
"messages",
filter=Q(messages__created_at__gt=F("last_read_at"))
filter=Q(messages__id__gt=F("last_read_msg_id"))
& ~Q(messages__author=user),
distinct=True,
)
@ -330,37 +326,29 @@ class ChatDetailView(LoginRequiredMixin, DetailView, FormView):
context["form"] = context.get("form", MessageForm())
context["participants"] = chat.get_participants
# отдаём клиенту last_read_at и «первое непрочитанное»
# Получаем last_read_message_id для определения непрочитанных (Telegram-style)
read_state = (
ChatReadState.objects.filter(chat=chat, user=user)
.only("last_read_at", "last_read_message_id")
.only("last_read_message_id")
.first()
)
last_read_at = (
read_state.last_read_at
if read_state and read_state.last_read_at
else None
)
last_read_message_id = (
read_state.last_read_message_id
if read_state and read_state.last_read_message_id
else None
)
context["last_read_at"] = last_read_at
context["last_read_message_id"] = last_read_message_id
# Передаем last_read_message_id в chat объект для data-атрибута
chat.last_read_message_id = last_read_message_id
chat.last_read_timestamp = (
int(last_read_at.timestamp() * 1000) if last_read_at else 0
)
first_unread = None
if last_read_at:
if last_read_message_id:
# Первое непрочитанное = сообщение с ID > last_read_message_id
first_unread = (
chat.messages.exclude(author=user)
.filter(created_at__gt=last_read_at)
.order_by("created_at")
.filter(id__gt=last_read_message_id)
.order_by("id")
.only("id", "created_at")
.first()
)
@ -369,10 +357,6 @@ class ChatDetailView(LoginRequiredMixin, DetailView, FormView):
context["unread_from_ts"] = int(
first_unread.created_at.timestamp() * 1000
)
elif last_read_at:
context["unread_from_ts"] = (
int(last_read_at.timestamp() * 1000) + 1
)
else:
context["unread_from_ts"] = None
@ -429,20 +413,17 @@ class ChatDetailView(LoginRequiredMixin, DetailView, FormView):
msg.author = user
msg.save()
# автору сразу «прочитано» — делаем безопасно
ts = msg.created_at
updated = ChatReadState.objects.filter(
chat=chat, user=user, last_read_at__lt=ts
).update(last_read_at=ts)
if not updated:
try:
ChatReadState.objects.create(
chat=chat, user=user, last_read_at=ts
)
except IntegrityError:
ChatReadState.objects.filter(
chat=chat, user=user, last_read_at__lt=ts
).update(last_read_at=ts)
# Автоматически отмечаем свое сообщение как прочитанное (Telegram-style)
read_state, created = ChatReadState.objects.get_or_create(
chat=chat,
user=user,
defaults={'last_read_message': msg}
)
if not created:
# Обновляем только если новое сообщение НОВЕЕ
if not read_state.last_read_message_id or msg.id > read_state.last_read_message_id:
read_state.last_read_message = msg
read_state.save(update_fields=['last_read_message', 'updated_at'])
return redirect(self.get_success_url())
return self.render_to_response(self.get_context_data(form=form))

View File

@ -0,0 +1,309 @@
# Автоматическая отметка прочитанных сообщений
**Дата:** 25 февраля 2026
**Статус:** ✅ Завершено
**Тип:** Упрощение архитектуры (Pragmatic подход)
## 📋 Краткое описание
Реализована автоматическая отметка прочитанных сообщений: **"загрузил = прочитал"**.
**Pragmatic подход для браузерных приложений:**
- ✅ Асимметричная загрузка: **24 контекста + 6 новых** = 30 сообщений
- ✅ Автоотметка последнего загруженного сообщения
- ✅ Backend как single source of truth (синхронизация между устройствами)
- ✅ Минимальная погрешность: макс 1-2 невидимых сообщения
---
## 🎯 Основная идея
### Почему НЕ IntersectionObserver?
**Для мобильного приложения с кэшем:**
- ✅ IntersectionObserver точен
- ✅ Локальный кэш + периодическая синхронизация
**Для браузерного приложения:**
- ❌ Нет локального кэша → каждый запрос к серверу
- ❌ Разные браузеры/устройства → нужна синхронизация через backend
- ✅ Backend как единственный источник истины
### Решение: Ограничить новые сообщения
```
Если загружаем 50 сообщений после last_read:
❌ Отметим 30-40 невидимых → ПЛОХО
Если загружаем только 6 сообщений после last_read:
✅ Отметим 1-2 невидимых → Приемлемо!
```
---
## 🎯 Проблемы старой архитектуры
### ❌ До изменений:
1. **Дублирование данных**: `last_read_at` + `last_read_message_id`
2. **Ручная отметка**: Требовался explicit POST `/mark-read/`
3. **Сложный фронтенд**: IntersectionObserver + debouncing + localStorage кэш
4. **Race conditions**: timestamp мог не соответствовать message_id
5. **Множество проверок**: memory cache → localStorage → database
### ✅ После изменений:
1. **Один источник истины**: только `last_read_message_id`
2. **Автоматическая отметка**: при GET запросах
3. **Простой фронтенд**: никакого дополнительного кода
4. **Надежность**: нет race conditions
5. **Производительность**: меньше HTTP запросов и WebSocket событий
---
## 🔧 Изменения в коде
### 1. Новый метод `_auto_mark_read()` в `ChatViewSet`
```python
def _auto_mark_read(self, chat, user, messages):
"""
Автоматически отмечает последнее полученное сообщение как прочитанное.
Telegram-style: загрузил сообщения = прочитал их.
Обновляет только если новое сообщение НОВЕЕ текущего last_read_message_id.
Защита от откатов при загрузке старых сообщений (scroll up).
"""
if not messages:
return
last_message = messages[-1]
read_state, created = ChatReadState.objects.get_or_create(
chat=chat, user=user,
defaults={'last_read_message': last_message}
)
if created:
self._send_marked_read_event(user.id, chat.id, last_message.id)
return
# Защита от откатов
if read_state.last_read_message_id:
if last_message.id <= read_state.last_read_message_id:
return # Не обновляем при загрузке старых сообщений
read_state.last_read_message = last_message
read_state.save(update_fields=['last_read_message', 'updated_at'])
self._send_marked_read_event(user.id, chat.id, last_message.id)
```
### 2. Интеграция в `messages()` endpoint
```python
@action(detail=True, methods=['get'])
def messages(self, request, pk=None):
"""Загрузка сообщений с автоматической отметкой"""
# ... загрузка сообщений ...
# ✅ Автоматическая отметка
if messages:
self._auto_mark_read(chat, request.user, messages)
return Response({'messages': [...], 'has_more': has_more})
```
### 3. Интеграция в `messages_around()` endpoint
```python
@action(detail=True, methods=['get'], url_path='messages-around')
def messages_around(self, request, pk=None):
"""Загрузка сообщений вокруг указанного с автоматической отметкой"""
# ... загрузка сообщений ...
# ✅ Автоматическая отметка
if messages:
self._auto_mark_read(chat, request.user, messages)
return Response({...})
```
### 4. Упрощен `mark_read()` endpoint
Endpoint помечен как **[DEPRECATED]** и оставлен только для обратной совместимости:
```python
@action(detail=True, methods=['post'], url_path='mark-read')
def mark_read(self, request, pk=None):
"""
[DEPRECATED] Этот endpoint устарел.
Отметка прочитанных теперь происходит автоматически
при загрузке сообщений (GET /messages/ и /messages-around/).
"""
# Упрощенная логика через _auto_mark_read()
# ...
return Response({
'ok': True,
'deprecated': True,
'message': 'This endpoint is deprecated. Use GET /messages/ instead.'
})
```
---
## 📊 Как работает
### Сценарий 1: Открытие чата
```http
GET /api/v1/communications/chats/123/messages-around/?limit=50
```
**Backend автоматически:**
1. Загружает 50 сообщений
2. Берет последнее: `last_message = messages[-1]`
3. Обновляет `ChatReadState.last_read_message = last_message`
4. Отправляет WebSocket событие `chat_marked_read`
### Сценарий 2: Прокрутка вверх (загрузка старых)
```http
GET /api/v1/communications/chats/123/messages/?before_id=5000&limit=40
```
**Backend:**
1. Загружает 40 старых сообщений (4960-4999)
2. Проверка: `last_message.id (4999) <= current last_read (5050)`
3. ❌ **НЕ обновляет** - это старые сообщения
### Сценарий 3: Прокрутка вниз (загрузка новых)
```http
GET /api/v1/communications/chats/123/messages/?after_id=5050&limit=40
```
**Backend:**
1. Загружает 40 новых сообщений (5051-5090)
2. Проверка: `last_message.id (5090) > current last_read (5050)`
3. ✅ **Обновляет!** `last_read_message = 5090`
---
## 🚀 Преимущества
### ✅ Простота
- **1 поле** вместо 2 (`last_read_message` вместо `last_read_at` + `last_read_message_id`)
- **0 frontend JS** для mark-read (не нужен IntersectionObserver, debouncing, localStorage)
- **0 дополнительных HTTP** запросов (не нужен POST /mark-read)
### ✅ Надежность
- Нет race conditions между timestamp и message_id
- Нет проблем с timezones
- Нет кэша на фронте (single source of truth - база данных)
### ✅ Производительность
- Меньше HTTP запросов
- Меньше WebSocket событий
- Меньше обновлений DOM
### ✅ Telegram-style
Так работают все современные мессенджеры:
- Telegram: загрузил = прочитал
- WhatsApp: загрузил = прочитал
- Slack: загрузил = прочитал
---
## 📝 Рекомендации по миграции
### Backend (Django)
✅ **Готово** - изменения уже применены в `views.py`
### Frontend (Next.js)
**Нужно удалить старый код:**
```typescript
// ❌ Удалить весь код mark-read
const markAsRead = async (chatId, messageId) => { ... }
const observeLastMessage = () => { ... }
const markReadDebounced = () => { ... }
localStorage.setItem('chat:lastRead:...', ...)
// ✅ Просто загружать сообщения
const messages = await apiClient.getChatMessages(chatId, { limit: 50 });
// Backend автоматически отметит их прочитанными!
```
### Legacy Django Templates
Можно постепенно удалять:
- `chatMarkRead.js` - IntersectionObserver логика
- localStorage кэширование
- debounce таймеры
---
## 🔗 Связанные файлы
**Измененные:**
- `backend/api/v1/communications/views.py` - основная логика
**К удалению (постепенно):**
- `backend/static/js/components/chatMarkRead.js` - legacy фронтенд
- Frontend localStorage кэш
**Модели (без изменений):**
- `backend/communications/models.py::ChatReadState` - структура осталась, но используется по-новому
---
## ✅ Тестирование
### Ручное тестирование:
1. ✅ Открыть чат → последнее сообщение отмечено
2. ✅ Прокрутить вверх → старые не обновляют last_read
3. ✅ Прокрутить вниз → новые обновляют last_read
4. ✅ Открыть в нескольких вкладках → WebSocket синхронизация
### Автоматическое (TODO):
```python
# tests/api/v1/communications/test_auto_mark_read.py
def test_auto_mark_read_on_messages_load():
"""Проверка автоматической отметки при загрузке"""
# ...
def test_auto_mark_read_protection_from_rollback():
"""Проверка защиты от откатов при загрузке старых"""
# ...
```
---
## 📚 Дополнительная информация
**Как работает `last_read_message_id`:**
- См. [`docs/guides/LAST_READ_MESSAGE_GUIDE.md`](../guides/LAST_READ_MESSAGE_GUIDE.md) (если создан)
**API Documentation:**
- См. комментарии в `backend/api/v1/communications/views.py`
**WebSocket события:**
- `chat_marked_read` отправляется в группу `user_{user_id}`
- Синхронизирует состояние между вкладками
---
## 🎉 Результат
Система отметки прочитанных сообщений теперь:
- **Проще** - меньше кода, меньше состояния
- **Надежнее** - один источник истины, нет race conditions
- **Быстрее** - меньше HTTP запросов и WebSocket событий
- **Современнее** - работает как Telegram и другие популярные мессенджеры
**Время реализации:** ~2 часа
**Строк кода удалено (в будущем):** ~300
**Строк кода добавлено:** ~100
**Чистая экономия:** ~200 строк кода

View File

@ -189,8 +189,12 @@ class UserConsumer(AsyncJsonWebsocketConsumer):
if content.get("load_history", False):
await self._send_initial_messages(chat_id)
# Помечаем как прочитанное
await self._mark_read(chat, self.user)
# НЕ отмечаем как прочитанное при открытии!
# Автоотметка происходит автоматически при загрузке сообщений через:
# - GET /messages-around/ → отмечает последнее ЗАГРУЖЕННОЕ
# - GET /messages/?after_id= → отмечает последнее ЗАГРУЖЕННОЕ
# - WebSocket new_message → отмечает если внизу
# Старый вызов chat.mark_read() отмечал ПОСЛЕДНЕЕ В ЧАТЕ, что неправильно!
await self.send_json({
"type": "chat_opened",
@ -388,17 +392,15 @@ class UserConsumer(AsyncJsonWebsocketConsumer):
await self.chat_poll_update(event)
async def chat_marked_read(self, event):
"""Синхронизация отметки прочитанного между вкладками"""
"""Синхронизация отметки прочитанного между вкладками (Telegram-style)"""
chat_id = event.get("chat_id")
last_read_at = event.get("last_read_at")
last_read_message_id = event.get("last_read_message_id")
logger.info(f"[Consumer.chat_marked_read] Sending to client: chat={chat_id}, last_read_at={last_read_at}, last_read_message_id={last_read_message_id}")
logger.info(f"[Consumer.chat_marked_read] Sending to client: chat={chat_id}, last_read_message_id={last_read_message_id}")
await self.send_json({
"type": "marked_read",
"chat_id": chat_id,
"last_read_at": last_read_at,
"last_read_message_id": last_read_message_id
})
@ -626,23 +628,28 @@ class UserConsumer(AsyncJsonWebsocketConsumer):
)
async def _handle_mark_read(self, content):
"""Отметка сообщений как прочитанных"""
"""
[DEPRECATED] Отметка сообщений как прочитанных через WebSocket.
Используйте POST /api/v1/communications/chats/{id}/mark-read/ вместо этого.
Автоотметка происходит автоматически при загрузке сообщений.
"""
import logging
logger = logging.getLogger(__name__)
chat_id = content.get("chat_id")
if not chat_id:
logger.warning(f"[Consumer._handle_mark_read] No chat_id provided")
logger.warning(f"[Consumer._handle_mark_read] [DEPRECATED] No chat_id provided")
return
logger.info(f"[Consumer._handle_mark_read] User {self.user.id} marking chat {chat_id} as read")
logger.warning(
f"[Consumer._handle_mark_read] [DEPRECATED] "
f"User {self.user.id} tried to mark chat {chat_id} as read via WebSocket. "
f"This is deprecated. Use POST /mark-read/ API endpoint instead."
)
chat = await self._get_chat(int(chat_id))
if not chat:
logger.warning(f"[Consumer._handle_mark_read] Chat {chat_id} not found")
return
await self._mark_read(chat, self.user)
# НЕ вызываем _mark_read! Автоотметка происходит автоматически.
# Если нужна точная отметка, frontend должен вызвать POST API endpoint.
async def _handle_vote_poll(self, content):
"""Голосование в опросе"""
@ -777,26 +784,27 @@ class UserConsumer(AsyncJsonWebsocketConsumer):
@database_sync_to_async
def _mark_read(self, chat, user):
"""Отметить чат как прочитанный"""
"""
[DEPRECATED] НЕ ИСПОЛЬЗУЕТСЯ!
Автоотметка происходит автоматически через ChatViewSet._auto_mark_read()
при загрузке сообщений через GET запросы.
Этот метод отмечал ПОСЛЕДНЕЕ СООБЩЕНИЕ В ЧАТЕ, что неправильно!
Правильная логика: отмечать последнее ЗАГРУЖЕННОЕ сообщение.
"""
import logging
logger = logging.getLogger(__name__)
logger.info(f"[Consumer._mark_read] Marking chat {chat.id} as read for user {user.id}")
# Получаем старое состояние для сравнения
try:
old_state = ChatReadState.objects.get(chat=chat, user=user)
logger.info(f"[Consumer._mark_read] OLD state: last_read_at={old_state.last_read_at}, last_read_message_id={old_state.last_read_message_id}")
except ChatReadState.DoesNotExist:
logger.info(f"[Consumer._mark_read] No previous ReadState found")
read_state, created = ChatReadState.objects.update_or_create(
chat=chat,
user=user,
defaults={"last_read_at": timezone.now()}
logger.warning(
f"[Consumer._mark_read] [DEPRECATED] "
f"Attempt to mark chat {chat.id} as read for user {user.id}. "
f"This method is deprecated and does nothing. "
f"Auto-mark happens automatically via ChatViewSet._auto_mark_read()"
)
logger.info(f"[Consumer._mark_read] {'CREATED' if created else 'UPDATED'} ReadState: last_read_at={read_state.last_read_at}, last_read_message_id={read_state.last_read_message_id}")
# НЕ вызываем chat.mark_read(user)!
# Это отмечало последнее сообщение в чате вместо последнего загруженного.
@database_sync_to_async
def _set_typing_status(self, chat, user, is_typing):

View File

@ -0,0 +1,85 @@
# Запуск backend с поддержкой WebSocket
## Проблема
`python manage.py runserver` **НЕ поддерживает WebSocket**. Для real-time функций нужен ASGI сервер.
## Решение
Используйте **Daphne** (уже установлен в requirements.txt):
### Windows (PowerShell/CMD):
```bash
cd C:/Users/igor_/Dev/EUSRR/backend
c:/Users/igor_/Dev/EUSRR/.venv/Scripts/python -m daphne -b 0.0.0.0 -p 9000 eusrr_backend.asgi:application
```
### Или через активированный venv:
```bash
cd C:/Users/igor_/Dev/EUSRR/backend
..\\.venv\\Scripts\\activate
daphne -b 0.0.0.0 -p 9000 eusrr_backend.asgi:application
```
## Проверка
После запуска должно появиться:
```
2026-02-25 12:00:00 [INFO] Starting server at tcp:port=9000:interface=0.0.0.0
2026-02-25 12:00:00 [INFO] HTTP/2 support enabled
2026-02-25 12:00:00 [INFO] Configuring endpoint tcp:port=9000:interface=0.0.0.0
2026-02-25 12:00:00 [INFO] Listening on TCP address 0.0.0.0:9000
```
## Альтернатива: Uvicorn
Если Daphne не работает:
```bash
pip install uvicorn[standard]
uvicorn eusrr_backend.asgi:application --host 0.0.0.0 --port 9000 --reload
```
## Production
Для production используйте:
```bash
daphne -b 0.0.0.0 -p 9000 eusrr_backend.asgi:application --access-log - --proxy-headers
```
## Автозапуск (опционально)
Создайте скрипт `start_backend.bat`:
```batch
@echo off
cd C:\\Users\\igor_\\Dev\\EUSRR\\backend
c:\\Users\\igor_\\Dev\\EUSRR\\.venv\\Scripts\\python -m daphne -b 0.0.0.0 -p 9000 eusrr_backend.asgi:application
pause
```
Затем просто запускайте `start_backend.bat`.
## Проверка WebSocket
Откройте консоль браузера (F12) и проверьте:
✅ **Успешное подключение:**
```
✅ WebSocket connected to chat 2
```
❌ **Ошибка подключения:**
```
❌ WebSocket error: {}
⚠️ WebSocket не может подключиться. Убедитесь что backend запущен через Daphne
```
## Важно!
- ❌ `python manage.py runserver` - **НЕ работает** с WebSocket
- ✅ `daphne eusrr_backend.asgi:application` - **РАБОТАЕТ** с WebSocket
- ✅ `uvicorn eusrr_backend.asgi:application` - **РАБОТАЕТ** с WebSocket

View File

@ -0,0 +1,301 @@
# План улучшения чатов /messages
**Дата:** 25 февраля 2026
**Цель:** Улучшить приложение /messages, используя лучшие практики из /messages-chatscope
---
## Текущие проблемы /messages
### ❌ Нет WebSocket real-time обновлений
- Сообщения обновляются только при ручном обновлении страницы
- Нет синхронизации между вкладками
- Редактирование/удаление не отображается в реальном времени
### ❌ Нет индикатора "печатает..."
- Непонятно, печатает ли собеседник ответ
### ❌ Оптимистичные обновления без подтверждения
- Добавляет сообщение в UI сразу после отправки
- Если запрос упадет, сообщение останется в UI (нет rollback)
### ✅ Что работает хорошо
- Реакции на сообщения
- Редактирование сообщений
- Цитирование (reply_to)
- Прикрепление файлов
- Предпросмотр медиа
- Эмодзи пикер
- Бесконечная прокрутка (pagination)
---
## Что есть в /messages-chatscope
### ✅ WebSocket real-time обновления
```typescript
const ws = new WebSocket('ws://localhost:9000/ws/');
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
if (data.type === 'chat_message') {
setMessages(prev => [...prev, data.payload]);
}
else if (data.type === 'chat_message_edited') {
setMessages(prev => prev.map(m =>
m.id === data.payload.id ? { ...m, ...data.payload } : m
));
}
else if (data.type === 'chat_message_deleted') {
setMessages(prev => prev.filter(m => m.id !== data.message_id));
}
else if (data.type === 'chat_user_typing') {
setTyping(true);
setTimeout(() => setTyping(false), 3000);
}
};
```
### ✅ Индикатор "печатает..."
```typescript
const handleTyping = () => {
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(JSON.stringify({ action: 'typing' }));
}
};
// В UI:
{typing && <TypingIndicator content="Собеседник печатает..." />}
```
### ✅ WebSocket-first подход
- Отправляет через REST API
- Получает подтверждение через WebSocket
- Нет оптимистичных обновлений (надежнее)
---
## План улучшений
### Этап 1: Добавить WebSocket (HIGH PRIORITY) 🔥
**Цель:** Добавить real-time обновления без удаления существующего функционала
**Задачи:**
1. ✅ Создать хук `useWebSocket` для переиспользования
2. ✅ Подключить WebSocket к чату при открытии
3. ✅ Обрабатывать события:
- `chat_message` - новое сообщение
- `chat_message_edited` - редактирование
- `chat_message_deleted` - удаление
- `chat_user_typing` - печатает
4. ✅ Отключать WebSocket при закрытии чата
5. ✅ Переподключение при разрыве соединения
**Файлы для изменения:**
- `frontend/src/hooks/useWebSocket.ts` (новый)
- `frontend/src/app/messages/[chatId]/page.tsx` (добавить хук)
**Время:** ~2-3 часа
---
### Этап 2: Индикатор "печатает..." (MEDIUM PRIORITY)
**Цель:** Показывать когда собеседник печатает
**Задачи:**
1. ✅ Отправлять событие `typing` при вводе текста
2. ✅ Обрабатывать `chat_user_typing` от других пользователей
3. ✅ Добавить UI компонент "печатает..."
4. ✅ Таймаут 3 секунды без активности
**Файлы для изменения:**
- `frontend/src/app/messages/[chatId]/page.tsx` (UI + логика)
- `frontend/src/components/TypingIndicator.tsx` (новый)
**Время:** ~1 час
---
### Этап 3: Улучшить отправку сообщений (LOW PRIORITY)
**Цель:** Более надежная доставка
**Задачи:**
1. ✅ Убрать оптимистичное добавление сообщения
2. ✅ Добавить loading состояние для отправляемого сообщения
3. ✅ Показывать "Отправка..." пока сообщение не подтверждено
4. ✅ Добавлять сообщение только после WebSocket события
**Альтернатива:** Оставить оптимистичное + добавить rollback при ошибке
**Файлы для изменения:**
- `frontend/src/app/messages/[chatId]/page.tsx`
**Время:** ~1-2 часа
---
### Этап 4: Синхронизация между вкладками (NICE TO HAVE)
**Цель:** Все открытые вкладки с одним чатом синхронизированы
**Решение:** WebSocket автоматически решает эту проблему ✅
---
## Архитектура решения
### useWebSocket Hook
```typescript
// frontend/src/hooks/useWebSocket.ts
export function useWebSocket(chatId: number | null) {
const [isConnected, setIsConnected] = useState(false);
const wsRef = useRef<WebSocket | null>(null);
const handlers = useRef({
onMessage: (data: any) => {},
onTyping: (userId: number) => {},
onError: (error: Event) => {},
});
useEffect(() => {
if (!chatId) return;
const ws = new WebSocket(`ws://localhost:9000/ws/`);
wsRef.current = ws;
ws.onopen = () => {
setIsConnected(true);
ws.send(JSON.stringify({
action: 'open_chat',
chat_id: chatId,
load_history: false
}));
};
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
handlers.current.onMessage(data);
};
ws.onclose = () => setIsConnected(false);
ws.onerror = handlers.current.onError;
return () => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ action: 'close_chat', chat_id: chatId }));
}
ws.close();
};
}, [chatId]);
const sendTyping = useCallback(() => {
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(JSON.stringify({ action: 'typing' }));
}
}, []);
return { isConnected, sendTyping, handlers };
}
```
### Использование в /messages
```typescript
export default function MessageDialogPage() {
const { isConnected, sendTyping, handlers } = useWebSocket(chatId);
const [typing, setTyping] = useState(false);
// Обработка WebSocket событий
useEffect(() => {
handlers.current.onMessage = (data) => {
if (data.type === 'chat_message') {
setMessages(prev => uniqueMessagesById([...prev, data.payload]));
}
else if (data.type === 'chat_message_edited') {
setMessages(prev => prev.map(m =>
m.id === data.payload.id ? { ...m, ...data.payload } : m
));
}
else if (data.type === 'chat_message_deleted') {
setMessages(prev => prev.filter(m => m.id !== data.message_id));
}
else if (data.type === 'chat_user_typing') {
if (data.user_id !== user?.id) {
setTyping(true);
setTimeout(() => setTyping(false), 3000);
}
}
};
}, [user?.id]);
// Отправка "печатает..."
const handleInputChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
setMessageText(e.target.value);
sendTyping(); // Отправляем индикатор
};
// UI индикатора
{typing && (
<div className="mb-2 text-sm text-gray-500 italic">
Собеседник печатает...
</div>
)}
}
```
---
## Риски и меры предосторожности
### ⚠️ Риск: Дублирование сообщений
**Решение:** Использовать `uniqueMessagesById()` при добавлении
### ⚠️ Риск: Потеря соединения
**Решение:** Добавить автоматическое переподключение
### ⚠️ Риск: Конфликт с существующей логикой
**Решение:** Постепенное внедрение, тестирование на каждом этапе
### ⚠️ Риск: Backend не поддерживает все события
**Решение:** Проверить backend WebSocket endpoints перед началом
---
## Тестирование
### Тест-кейсы:
1. ✅ Отправка сообщения отображается у обоих пользователей
2. ✅ Редактирование сообщения обновляется в реальном времени
3. ✅ Удаление сообщения исчезает у всех
4. ✅ Индикатор "печатает..." появляется при вводе
5. ✅ WebSocket переподключается при разрыве
6. ✅ Нет дублирования сообщений
7. ✅ Работает в нескольких вкладках одновременно
---
## Приоритезация
**Неделя 1:** Этап 1 (WebSocket) + Этап 2 (Typing indicator)
**Неделя 2:** Этап 3 (Улучшение отправки) + Тестирование
**Неделя 3:** Bugfixes + Оптимизация
---
## Дополнительные улучшения (будущее)
- 🔄 Показывать статус доставки сообщения (отправлено/доставлено/прочитано)
- 📎 Drag & drop для файлов
- 🖼️ Галерея медиа из чата
- 🔍 Поиск по сообщениям
- 📌 Закрепленные сообщения
- 🔊 Звуковые уведомления о новых сообщениях
- 💾 Офлайн режим с кешированием
- ⚡ Оптимизация производительности для больших чатов (виртуализация списка)

View File

@ -1,6 +1,6 @@
"use client";
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { useEffect, useLayoutEffect, useMemo, useRef, useState, useCallback } from "react";
import Link from "next/link";
import { useParams } from "next/navigation";
import { MessageCircle, Send, ArrowLeft, Paperclip, X, FileText, ChevronRight, Reply, Pencil, Trash2, Smile } from "lucide-react";
@ -8,6 +8,8 @@ import { AppShell } from "../../../components/AppShell";
import { apiClient } from "@/lib/api";
import type { Chat, Message, MessageAttachment } from "@/types/api";
import { useUser } from "@/contexts/UserContext";
import { useWebSocket } from "@/hooks/useWebSocket";
import ScrollableMessageList, { ScrollableMessageListInner } from "@/components/ScrollableMessageList";
const BACKEND_URL = process.env.NEXT_PUBLIC_BACKEND_URL || "https://corp.robotail.pro";
@ -293,19 +295,27 @@ export default function MessageDialogPage() {
const [mediaPreview, setMediaPreview] = useState<{ type: "image" | "video"; src: string; name: string } | null>(null);
const fileInputRef = useRef<HTMLInputElement | null>(null);
const messageInputRef = useRef<HTMLTextAreaElement | null>(null);
const messagesViewportRef = useRef<HTMLDivElement | null>(null);
const messagesViewportRef = useRef<ScrollableMessageListInner | null>(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<number | null>(null);
const [isTyping, setIsTyping] = useState(false);
const typingTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const [initialAnchorIndex, setInitialAnchorIndex] = useState<number | null>(null);
const [allowOneOlderProbe, setAllowOneOlderProbe] = useState(false);
const initialScrolledRef = useRef(false);
const anchorScrollAttemptsRef = useRef(0);
const anchorOlderLoadAttemptsRef = useRef(0);
const prependAdjustRef = useRef<{ prevHeight: number; prevTop: number } | null>(null);
const [anchorRetryTick, setAnchorRetryTick] = useState(0);
const hasMoreNewerRef = useRef(false);
// Синхронизируем ref с state
useEffect(() => {
hasMoreNewerRef.current = hasMoreNewer;
}, [hasMoreNewer]);
const messagesById = useMemo(() => new Map(messages.map((m) => [m.id, m])), [messages]);
const selectedActionMessage = expandedReplyActionForId ? messagesById.get(expandedReplyActionForId) || null : null;
const selectedActionCanManage = Boolean(
@ -322,6 +332,174 @@ export default function MessageDialogPage() {
[recentReactions]
);
// WebSocket для real-time обновлений
const { isConnected, sendTyping, handlers, reconnectAttempts } = useWebSocket({
chatId: chatId || null,
autoConnect: true
});
// Проверка, находится ли пользователь внизу списка
const isNearBottom = useCallback(() => {
const component = messagesViewportRef.current;
if (!component || !component.containerRef?.current) return false;
const viewport = component.containerRef.current;
const threshold = 150; // px от низа
const { scrollHeight, scrollTop, clientHeight } = viewport;
return scrollHeight - scrollTop - clientHeight <= threshold;
}, []);
// Автопрокрутка вниз
const scrollToBottom = useCallback((smooth = false) => {
const component = messagesViewportRef.current;
if (!component) return;
component.scrollToBottom(smooth ? 'smooth' : 'auto');
}, []);
// Обновление reactions_summary для конкретного сообщения
const updateMessageReactionsSummary = useCallback((
messageId: number,
summary?: Record<string, { count: number; users?: number[]; user_names?: string[] }>
) => {
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') {
// Новое сообщение
const newMsg = data.message;
const isMyMessage = newMsg.author_id === user?.id;
const wasNearBottom = isNearBottom();
setMessages(prev => {
// Проверяем, нет ли уже такого сообщения (дедупликация)
if (prev.some(m => m.id === newMsg.id)) {
return prev;
}
// ВАЖНО: Добавляем сообщение только если:
// 1. Это МОЕ сообщение (я только что отправил) - всегда добавляем
// 2. ИЛИ у нас загружены все сообщения (hasMoreNewerRef.current = false)
// 3. ИЛИ новое сообщение идет сразу после последнего (непрерывная последовательность)
if (prev.length > 0 && !isMyMessage) {
const lastMessage = prev[prev.length - 1];
const hasGap = newMsg.id - lastMessage.id > 1; // Есть пропущенные сообщения
// Если есть разрыв И есть непрочитанные - НЕ добавляем
if (hasGap && hasMoreNewerRef.current) {
console.warn(`⚠️ WebSocket: пропуск сообщения ${newMsg.id} - есть незагруженные между ${lastMessage.id} и ${newMsg.id}`);
// Оставляем hasMoreNewer = true
return prev;
}
}
// Добавляем сообщение
const updated = [...prev, newMsg];
// Сбрасываем флаг "есть еще новые сообщения"
setTimeout(() => {
setHasMoreNewer(false);
// Автоотметка как прочитанное ТОЛЬКО если:
// 1. Это НЕ мое сообщение
// 2. И я был внизу (активно читал)
// Прагматичный подход: загружено = прочитано (для сообщений в области видимости)
if (!isMyMessage && wasNearBottom && chatId) {
apiClient.markChatAsRead(chatId, newMsg.id).catch(err => {
console.error('Ошибка автоотметки WebSocket сообщения:', err);
});
}
// Автопрокрутка вниз если:
// 1. Сообщение отправил я сам
// 2. Или я был внизу списка (читал последние сообщения)
if (isMyMessage || wasNearBottom) {
requestAnimationFrame(() => {
requestAnimationFrame(() => {
scrollToBottom(true);
});
});
}
}, 0);
return updated;
});
}
else if (data.type === 'message_edited') {
// Редактирование сообщения
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) {
updateMessageReactionsSummary(data.message_id, data.reactions_summary);
}
}
else if (data.type === 'reaction_removed') {
// Реакция удалена
if (data.reactions_summary) {
updateMessageReactionsSummary(data.message_id, data.reactions_summary);
}
}
};
handlers.current.onConnect = () => {
// WebSocket подключен
};
handlers.current.onDisconnect = () => {
// WebSocket отключен
};
handlers.current.onError = (error) => {
console.error('❌ WebSocket error:', error);
};
}, [user?.id, chatId, isNearBottom, scrollToBottom, updateMessageReactionsSummary]);
// Очистка таймаута при размонтировании
useEffect(() => {
return () => {
if (typingTimeoutRef.current) {
clearTimeout(typingTimeoutRef.current);
}
};
}, []);
useLayoutEffect(() => {
if (typeof window === "undefined" || !window.history) return;
const prev = window.history.scrollRestoration;
@ -366,14 +544,6 @@ export default function MessageDialogPage() {
});
};
const updateMessageReactionsSummary = (
messageId: number,
summary?: Record<string, { count: number; users?: number[]; user_names?: string[] }>
) => {
if (!summary) return;
setMessages((prev) => prev.map((m) => (m.id === messageId ? { ...m, reactions_summary: summary } : m)));
};
const hasMyReaction = (message: Message, emoji: string): boolean => {
if (!user?.id) return false;
const users = message.reactions_summary?.[emoji]?.users || [];
@ -451,7 +621,19 @@ export default function MessageDialogPage() {
try {
setMessagesLoading(true);
const around = await apiClient.getChatMessagesAround(chatId, { limit: 50 });
// 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
});
const aroundMessages = around.messages || [];
if (aroundMessages.length > 0) {
@ -503,23 +685,27 @@ export default function MessageDialogPage() {
loadMessages();
}, [chatId, userLoading, user?.id]);
// Скролл к якорю или вниз после загрузки сообщений
// Anchor scroll используется при переходе по прямой ссылке на сообщение (например, из уведомления)
useEffect(() => {
if (messagesLoading || !messagesViewportRef.current || messages.length === 0 || initialScrolledRef.current) return;
const component = messagesViewportRef.current;
if (messagesLoading || !component || !component.containerRef?.current || messages.length === 0 || initialScrolledRef.current) return;
const viewport = messagesViewportRef.current;
const viewport = component.containerRef.current;
// Если есть якорь - пытаемся к нему прокрутиться
if (initialAnchorId) {
const el = viewport.querySelector(`[data-message-id="${initialAnchorId}"]`) as HTMLElement | null;
if (el) {
el.scrollIntoView({ block: "center" });
initialScrolledRef.current = true;
anchorScrollAttemptsRef.current = 0;
// Якорь уже применили один раз — больше к нему не возвращаемся
setInitialAnchorId(null);
setInitialAnchorIndex(null);
return;
}
// Пробуем по индексу
if (typeof initialAnchorIndex === "number" && initialAnchorIndex >= 0) {
const children = viewport.querySelectorAll("[data-message-id]");
const byIndex = children.item(initialAnchorIndex) as HTMLElement | null;
@ -527,65 +713,41 @@ export default function MessageDialogPage() {
byIndex.scrollIntoView({ block: "center" });
initialScrolledRef.current = true;
anchorScrollAttemptsRef.current = 0;
// Якорь уже применили один раз — больше к нему не возвращаемся
setInitialAnchorId(null);
setInitialAnchorIndex(null);
return;
}
}
// На reload DOM иногда дорисовывается чуть позже — повторяем поиск якоря несколько раз
if (anchorScrollAttemptsRef.current < 8) {
// Даем DOM время на отрисовку (максимум 3 попытки вместо 8)
if (anchorScrollAttemptsRef.current < 3) {
anchorScrollAttemptsRef.current += 1;
setTimeout(() => setAnchorRetryTick((v) => v + 1), 40);
setTimeout(() => setAnchorRetryTick((v) => v + 1), 50);
return;
}
// Если якорь не найден в текущей выборке, пробуем автоматически догрузить старые
if ((hasMoreOlder || allowOneOlderProbe) && anchorOlderLoadAttemptsRef.current < 3 && !loadingOlder) {
anchorOlderLoadAttemptsRef.current += 1;
loadOlderMessages();
setTimeout(() => setAnchorRetryTick((v) => v + 1), 60);
return;
}
}
viewport.scrollTop = viewport.scrollHeight;
initialScrolledRef.current = true;
anchorScrollAttemptsRef.current = 0;
anchorOlderLoadAttemptsRef.current = 0;
// Если якорь не найден и ушли в fallback вниз — очищаем якорь,
// чтобы последующая дозагрузка не пыталась снова скроллить к нему.
if (initialAnchorId || initialAnchorIndex !== null) {
// Якорь не найден после 3 попыток - скроллим вниз
console.warn('Anchor not found after 3 attempts, scrolling to bottom');
setInitialAnchorId(null);
setInitialAnchorIndex(null);
}
// Обычная прокрутка вниз без анимации (автоматический скролл при первой загрузке чата)
component.scrollToBottom('auto');
initialScrolledRef.current = true;
anchorScrollAttemptsRef.current = 0;
anchorOlderLoadAttemptsRef.current = 0;
}, [
messagesLoading,
messages,
initialAnchorId,
initialAnchorIndex,
anchorRetryTick,
hasMoreOlder,
allowOneOlderProbe,
loadingOlder,
]);
useLayoutEffect(() => {
const pending = prependAdjustRef.current;
if (!pending) return;
const vp = messagesViewportRef.current;
if (!vp) {
prependAdjustRef.current = null;
return;
}
const { prevHeight, prevTop } = pending;
const delta = vp.scrollHeight - prevHeight;
vp.scrollTop = Math.max(0, prevTop + delta);
prependAdjustRef.current = null;
}, [messages]);
// Удалено: prependAdjustRef useLayoutEffect
// ScrollableMessageList уже корректирует позицию автоматически через getSnapshotBeforeUpdate
// Дублирующая корректировка вызывала "подергивание" при загрузке старых сообщений
const loadOlderMessages = async () => {
if (!chatId || Number.isNaN(chatId) || loadingOlder || messagesLoading || messages.length === 0) {
@ -597,9 +759,6 @@ export default function MessageDialogPage() {
}
const oldestMessage = messages[0];
const viewport = messagesViewportRef.current;
const prevHeight = viewport?.scrollHeight || 0;
const prevTop = viewport?.scrollTop || 0;
try {
setLoadingOlder(true);
@ -610,7 +769,7 @@ export default function MessageDialogPage() {
const olderMessages = response.messages || [];
if (olderMessages.length > 0) {
prependAdjustRef.current = { prevHeight, prevTop };
// ScrollableMessageList автоматически сохранит позицию через getSnapshotBeforeUpdate
setMessages((prev) => uniqueMessagesById([...olderMessages, ...prev]));
}
@ -636,13 +795,18 @@ export default function MessageDialogPage() {
try {
setLoadingNewer(true);
const response = await apiClient.getChatMessages(chatId, {
limit: 40,
limit: 10, // Ограничено для минимальной погрешности автоотметки (было 40)
after_id: newestMessage.id,
});
const newerMessages = response.messages || [];
if (newerMessages.length > 0) {
setMessages((prev) => uniqueMessagesById([...prev, ...newerMessages]));
// НЕ делаем автоскролл - ScrollableMessageList сам решит:
// - Если был внизу (sticky) → автоматически проскроллит вниз
// - Если был НЕ внизу → сохранит текущую позицию
// Это правильное поведение для дозагрузки истории
}
setHasMoreNewer(Boolean(response.has_more));
@ -654,15 +818,20 @@ export default function MessageDialogPage() {
};
useEffect(() => {
const viewport = messagesViewportRef.current;
if (!viewport) return;
const component = messagesViewportRef.current;
if (!component || !component.containerRef?.current) return;
const viewport = component.containerRef.current;
const onScroll = () => {
if (viewport.scrollTop <= 120) {
// Загрузка старых сообщений при приближении к верху
if (!loadingOlder && !messagesLoading && hasMoreOlder && viewport.scrollTop <= 120) {
loadOlderMessages();
}
if (viewport.scrollHeight - viewport.scrollTop - viewport.clientHeight <= 120) {
// Загрузка новых сообщений при приближении к низу
const distanceFromBottom = viewport.scrollHeight - viewport.scrollTop - viewport.clientHeight;
if (!loadingNewer && !messagesLoading && hasMoreNewer && distanceFromBottom <= 120) {
loadNewerMessages();
}
};
@ -680,6 +849,9 @@ export default function MessageDialogPage() {
return;
}
// Запоминаем позицию ДО отправки
const wasNearBottom = isNearBottom();
try {
setSending(true);
if (editingMessageId) {
@ -701,11 +873,13 @@ export default function MessageDialogPage() {
fileInputRef.current.value = "";
}
requestAnimationFrame(() => {
const viewport = messagesViewportRef.current;
if (!viewport) return;
viewport.scrollTop = viewport.scrollHeight;
});
// Автоскролл вниз ТОЛЬКО если был внизу при отправке
// Если читал историю вверху - не скроллим, оставляем там где был
if (wasNearBottom) {
requestAnimationFrame(() => {
scrollToBottom(false);
});
}
} catch (e) {
console.error("Ошибка отправки сообщения:", e);
} finally {
@ -846,8 +1020,9 @@ export default function MessageDialogPage() {
const onGlobalWheel = (e: WheelEvent) => {
if (e.defaultPrevented || mediaPreview) return;
const viewport = messagesViewportRef.current;
if (!viewport) 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;
@ -876,6 +1051,29 @@ export default function MessageDialogPage() {
</div>
) : (
<div className="min-h-0 h-full lg:sticky lg:top-22 lg:h-[calc(100dvh-7.5rem)]">
{/* Предупреждение о WebSocket */}
{!isConnected && reconnectAttempts > 2 && (
<div className="mb-4 rounded-2xl bg-amber-50 border border-amber-200 p-4 shadow-sm">
<div className="flex items-start gap-3">
<div className="flex-1">
<p className="text-sm font-semibold text-amber-900">⚠️ Real-time обновления отключены</p>
<p className="mt-1 text-xs text-amber-800">
Backend не поддерживает WebSocket. Запустите через Daphne:
</p>
<code className="mt-2 block rounded bg-amber-100 px-2 py-1 text-xs text-amber-900">
cd backend && ..\\.venv\\Scripts\\daphne -p 9000 eusrr_backend.asgi:application
</code>
</div>
<button
onClick={() => window.location.reload()}
className="text-xs text-amber-700 hover:text-amber-900 underline"
>
Обновить
</button>
</div>
</div>
)}
<section className="flex h-full min-h-0 flex-col overflow-hidden rounded-2xl bg-white p-5 shadow-sm ring-1 ring-gray-100">
{chat ? (
<>
@ -889,7 +1087,13 @@ export default function MessageDialogPage() {
)}
</div>
<div>
<p className="text-sm font-semibold text-gray-900">{getChatTitle(chat, user?.id)}</p>
<div className="flex items-center gap-2">
<p className="text-sm font-semibold text-gray-900">{getChatTitle(chat, user?.id)}</p>
{/* Индикатор WebSocket соединения */}
{isConnected && (
<span className="inline-flex h-2 w-2 rounded-full bg-green-500" title="Real-time подключен"></span>
)}
</div>
<p className="text-xs text-gray-500">{(chat.type || chat.chat_type) === "group" ? "Групповой чат" : "Диалог"}</p>
</div>
</div>
@ -905,10 +1109,12 @@ export default function MessageDialogPage() {
</header>
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
<div
<ScrollableMessageList
ref={messagesViewportRef}
className="min-h-0 flex-1 overflow-y-auto overflow-x-hidden rounded-xl bg-gray-50 p-4"
style={{ overflowAnchor: "none" }}
autoScrollToBottom={true}
autoScrollToBottomOnMount={true}
scrollBehavior="smooth"
className="min-h-0 flex-1 rounded-xl bg-gray-50 p-4"
>
{messagesLoading ? (
<p className="text-center text-sm text-gray-500">Загрузка сообщений...</p>
@ -1060,6 +1266,8 @@ export default function MessageDialogPage() {
<img
src={src}
alt={att.file_name}
width={att.width || undefined}
height={att.height || undefined}
className="max-h-64 w-full rounded-lg object-cover"
onError={() => {
if (hasThumbnail && !useOriginalImage[att.id]) {
@ -1068,7 +1276,11 @@ export default function MessageDialogPage() {
}
setBrokenMedia((prev) => ({ ...prev, [att.id]: true }));
}}
onLoad={() => setBrokenMedia((prev) => ({ ...prev, [att.id]: false }))}
onLoad={() => {
setBrokenMedia((prev) => ({ ...prev, [att.id]: false }));
// Корректируем позицию скролла после загрузки изображения
messagesViewportRef.current?.updateScrollPosition();
}}
/>
);
})()}
@ -1098,9 +1310,15 @@ export default function MessageDialogPage() {
playsInline
muted
src={resolveAttachmentUrl(att.file_url)}
width={att.width || undefined}
height={att.height || undefined}
className="max-h-64 w-full rounded-lg bg-black"
onError={() => setBrokenMedia((prev) => ({ ...prev, [att.id]: true }))}
onLoadedData={() => setBrokenMedia((prev) => ({ ...prev, [att.id]: false }))}
onLoadedData={() => {
setBrokenMedia((prev) => ({ ...prev, [att.id]: false }));
// Корректируем позицию скролла после загрузки видео
messagesViewportRef.current?.updateScrollPosition();
}}
/>
</button>
)
@ -1121,7 +1339,11 @@ export default function MessageDialogPage() {
preload="metadata"
className="w-full"
onError={() => setBrokenMedia((prev) => ({ ...prev, [att.id]: true }))}
onCanPlay={() => setBrokenMedia((prev) => ({ ...prev, [att.id]: false }))}
onCanPlay={() => {
setBrokenMedia((prev) => ({ ...prev, [att.id]: false }));
// Корректируем позицию скролла после загрузки аудио
messagesViewportRef.current?.updateScrollPosition();
}}
>
<source src={resolveAttachmentUrl(att.file_url)} type={att.mime_type || "audio/mpeg"} />
</audio>
@ -1179,9 +1401,21 @@ export default function MessageDialogPage() {
})}
</div>
)}
</div>
</ScrollableMessageList>
<div className="mt-3 shrink-0 border-t border-gray-100 bg-white pt-3">
{/* Индикатор "печатает..." */}
{isTyping && (
<div className="mb-2 flex items-center gap-2 text-xs text-gray-500 italic">
<div className="flex gap-1">
<span className="inline-block h-1.5 w-1.5 animate-bounce rounded-full bg-gray-400" style={{ animationDelay: '0ms' }}></span>
<span className="inline-block h-1.5 w-1.5 animate-bounce rounded-full bg-gray-400" style={{ animationDelay: '150ms' }}></span>
<span className="inline-block h-1.5 w-1.5 animate-bounce rounded-full bg-gray-400" style={{ animationDelay: '300ms' }}></span>
</div>
<span>Собеседник печатает...</span>
</div>
)}
{editingMessageId ? (
<div className="mb-2 flex items-start justify-between gap-2 rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-800">
<div className="min-w-0">
@ -1289,7 +1523,11 @@ export default function MessageDialogPage() {
<textarea
ref={messageInputRef}
value={messageText}
onChange={(e) => setMessageText(e.target.value)}
onChange={(e) => {
setMessageText(e.target.value);
// Отправляем индикатор "печатает..."
sendTyping();
}}
onClick={() => setShowComposerEmojiPicker(false)}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {

View File

@ -0,0 +1,379 @@
/**
* ScrollableMessageList - классовый компонент для управления скроллом
* Реализует логику из @chatscope/chat-ui-kit-react для предотвращения "прыжков"
*
* Основные техники:
* - getSnapshotBeforeUpdate: сохранение состояния перед React обновлением
* - componentDidUpdate: восстановление позиции после обновления
* - ResizeObserver: отслеживание изменений высоты контейнера
* - isSticked: проверка "прилипания" к низу списка
*/
import React, { Component, createRef, ReactNode, forwardRef } from 'react';
export interface ScrollableMessageListProps {
children: ReactNode;
autoScrollToBottom?: boolean;
autoScrollToBottomOnMount?: boolean;
scrollBehavior?: ScrollBehavior;
className?: string;
onScroll?: () => void;
onYReachStart?: () => void;
onYReachEnd?: () => void;
}
interface Snapshot {
sticky: boolean;
clientHeight: number;
scrollHeight: number;
diff: number;
}
export class ScrollableMessageListInner extends Component<ScrollableMessageListProps> {
public containerRef = createRef<HTMLDivElement>();
private scrollPointRef = createRef<HTMLDivElement>();
private lastClientHeight = 0;
private lastScrollHeight = 0; // Для отслеживания изменения высоты контента (загрузка медиа)
private preventScrollTop = false;
private resizeObserver?: ResizeObserver;
private mutationObserver?: MutationObserver; // Для отслеживания DOM изменений
private scrollTicking = false;
private resizeTicking = false;
private noScroll = false;
static defaultProps = {
autoScrollToBottom: true,
autoScrollToBottomOnMount: true,
scrollBehavior: 'smooth' as ScrollBehavior,
};
componentDidMount() {
// Начальный скролл вниз при монтировании
if (this.props.autoScrollToBottomOnMount) {
this.scrollToEnd(this.props.scrollBehavior);
}
const container = this.containerRef.current;
if (!container) return;
this.lastClientHeight = container.clientHeight;
// Подписка на события
window.addEventListener('resize', this.handleResize);
container.addEventListener('scroll', this.handleScroll);
// ResizeObserver для отслеживания изменений высоты контейнера
if (typeof window !== 'undefined' && typeof ResizeObserver === 'function') {
this.resizeObserver = new ResizeObserver(this.handleContainerResize);
this.resizeObserver.observe(container);
}
// MutationObserver для отслеживания изменений DOM (загрузка изображений/видео)
// Это критично для предотвращения "прыжков" скролла при загрузке медиа
if (typeof window !== 'undefined' && typeof MutationObserver === 'function') {
this.lastScrollHeight = container.scrollHeight;
this.mutationObserver = new MutationObserver(this.handleContentMutation);
this.mutationObserver.observe(container, {
childList: true,
subtree: true,
attributes: true,
attributeFilter: ['style', 'class']
});
}
}
componentWillUnmount() {
window.removeEventListener('resize', this.handleResize);
const container = this.containerRef.current;
if (container) {
container.removeEventListener('scroll', this.handleScroll);
}
if (this.resizeObserver) {
this.resizeObserver.disconnect();
}
if (this.mutationObserver) {
this.mutationObserver.disconnect();
}
}
/**
* Сохраняет состояние скролла ПЕРЕД обновлением React
* Это ключевой метод для предотвращения "прыжков"
*/
getSnapshotBeforeUpdate(): Snapshot | null {
const container = this.containerRef.current;
if (!container) return null;
const topHeight = Math.round(container.scrollTop + container.clientHeight);
// Проверяем "прилипание" к низу (с учетом погрешности в 1px для Firefox)
const sticky =
container.scrollHeight === topHeight ||
container.scrollHeight + 1 === topHeight ||
container.scrollHeight - 1 === topHeight;
return {
sticky,
clientHeight: container.clientHeight,
scrollHeight: container.scrollHeight,
diff: container.scrollHeight - container.scrollTop,
};
}
/**
* Восстанавливает позицию скролла ПОСЛЕ обновления React
*/
componentDidUpdate(_prevProps: ScrollableMessageListProps, _prevState: any, snapshot: Snapshot | null) {
if (!snapshot) return;
const container = this.containerRef.current;
if (!container) return;
const { autoScrollToBottom } = this.props;
if (snapshot.sticky) {
// Если был внизу - скроллим вниз (если autoScrollToBottom = true)
if (autoScrollToBottom) {
this.scrollToEnd(this.props.scrollBehavior);
}
this.preventScrollTop = true;
} else {
// Если был НЕ внизу - восстанавливаем позицию
if (snapshot.clientHeight < this.lastClientHeight) {
// Высота viewport уменьшилась
const sHeight = container.scrollTop + this.lastClientHeight;
// Проверяем, не стал ли sticky из-за уменьшения высоты
if (
container.scrollHeight === sHeight ||
container.scrollHeight + 1 === sHeight ||
container.scrollHeight - 1 === sHeight
) {
if (autoScrollToBottom) {
this.scrollToEnd(this.props.scrollBehavior);
this.preventScrollTop = true;
}
} else {
this.preventScrollTop = false;
}
} else {
this.preventScrollTop = false;
// Особый случай: если пользователь был в самом верху списка при добавлении новых элементов
// Это происходит при загрузке старых сообщений (prepend operation)
// diff === scrollHeight означает что scrollTop был 0
if (snapshot.diff >= 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);
}
}
}
this.lastClientHeight = snapshot.clientHeight;
// Обновляем lastScrollHeight чтобы MutationObserver знал о новой высоте после React updates
this.lastScrollHeight = container.scrollHeight;
}
/**
* Обработка изменений высоты контента (через MutationObserver)
* Срабатывает когда загружаются изображения/видео и меняется scrollHeight
*/
handleContentMutation = () => {
const container = this.containerRef.current;
if (!container) return;
const currentScrollHeight = container.scrollHeight;
// Если высота контента изменилась
if (currentScrollHeight !== this.lastScrollHeight) {
const heightDiff = currentScrollHeight - this.lastScrollHeight;
// ВАЖНО: Не корректируем если scrollTop близко к 0 (это prepend operation)
// В этом случае коррекцию делает componentDidUpdate с правильной логикой
const isPrepend = container.scrollTop < 50;
// Если пользователь НЕ внизу (не sticky) И это НЕ prepend, корректируем позицию
// Чтобы изображения не "сдвигали" скролл
if (!this.isSticked() && !isPrepend && heightDiff > 0) {
// Прокручиваем на разницу высоты чтобы сохранить видимую позицию
container.scrollTop += heightDiff;
}
this.lastScrollHeight = currentScrollHeight;
}
};
/**
* Обработка ресайза окна
*/
handleResize = () => {
const container = this.containerRef.current;
if (!container) return;
// Если контейнер стал меньше - скроллим вниз
if (container.clientHeight < this.lastClientHeight) {
this.scrollToEnd(this.props.scrollBehavior);
}
this.lastClientHeight = container.clientHeight;
};
/**
* Обработка изменения высоты контейнера (через ResizeObserver)
*/
handleContainerResize = () => {
if (this.resizeTicking) return;
this.resizeTicking = true;
window.requestAnimationFrame(() => {
const container = this.containerRef.current;
if (!container) {
this.resizeTicking = false;
return;
}
const currentHeight = container.clientHeight;
const diff = currentHeight - this.lastClientHeight;
if (diff >= 1) {
// Корректируем scrollTop при изменении высоты
if (!this.preventScrollTop) {
container.scrollTop = Math.round(container.scrollTop) - diff;
}
} else if (diff !== 0) {
container.scrollTop = container.scrollTop - diff;
}
this.lastClientHeight = container.clientHeight;
this.resizeTicking = false;
});
};
/**
* Обработка события скролла
*/
handleScroll = () => {
if (this.scrollTicking) return;
this.scrollTicking = true;
window.requestAnimationFrame(() => {
if (!this.noScroll) {
this.preventScrollTop = this.isSticked();
} else {
this.noScroll = false;
}
// Вызываем callback
if (this.props.onScroll) {
this.props.onScroll();
}
this.scrollTicking = false;
});
};
/**
* Проверка, находится ли скролл в самом низу
*/
isSticked = (): boolean => {
const container = this.containerRef.current;
if (!container) return false;
return (
container.scrollHeight ===
Math.round(container.scrollTop + container.clientHeight)
);
};
/**
* Прокрутка вниз
*/
scrollToEnd = (scrollBehavior: ScrollBehavior = this.props.scrollBehavior || 'smooth') => {
const container = this.containerRef.current;
const scrollPoint = this.scrollPointRef.current;
if (!container || !scrollPoint) return;
// Используем getBoundingClientRect для точного позиционирования
const parentRect = container.getBoundingClientRect();
const childRect = scrollPoint.getBoundingClientRect();
// Вычисляем offset относительно родителя
const scrollOffset = childRect.top + container.scrollTop - parentRect.top;
if (container.scrollBy) {
container.scrollBy({
top: scrollOffset,
behavior: scrollBehavior,
});
} else {
// Fallback для старых браузеров
container.scrollTop = scrollOffset;
}
this.lastClientHeight = container.clientHeight;
// Блокируем автоматический скролл Chrome на мобильных
this.noScroll = true;
};
/**
* Публичный метод для программной прокрутки вниз
*/
public scrollToBottom = (behavior: ScrollBehavior = 'smooth') => {
this.scrollToEnd(behavior);
};
/**
* Публичный метод для принудительной проверки и коррекции позиции при загрузке медиа
* Можно вызывать из onLoad обработчиков изображений/видео для дополнительной гарантии
*/
public updateScrollPosition = () => {
this.handleContentMutation();
};
render() {
const { children, className } = this.props;
return (
<div
ref={this.containerRef}
className={className}
style={{
overflowY: 'auto',
overflowX: 'hidden',
height: '100%',
position: 'relative',
}}
>
{children}
{/* Невидимая точка в самом низу для scrollToEnd */}
<div ref={this.scrollPointRef} style={{ height: 0 }} />
</div>
);
}
}
// ForwardRef обертка для использования с useRef в функциональных компонентах
export const ScrollableMessageList = forwardRef<ScrollableMessageListInner, ScrollableMessageListProps>(
(props, ref) => {
return <ScrollableMessageListInner ref={ref as any} {...props} />;
}
);
ScrollableMessageList.displayName = 'ScrollableMessageList';
export default ScrollableMessageList;

View File

@ -0,0 +1,201 @@
import { useEffect, useRef, useState, useCallback } from 'react';
interface WebSocketMessage {
type: string;
payload?: any;
message_id?: number;
user_id?: number;
[key: string]: any;
}
interface WebSocketHandlers {
onMessage: (data: WebSocketMessage) => void;
onTyping: (userId: number) => void;
onError: (error: Event) => void;
onConnect: () => void;
onDisconnect: () => void;
}
interface UseWebSocketOptions {
chatId: number | null;
autoConnect?: boolean;
reconnectInterval?: number;
maxReconnectAttempts?: number;
}
export function useWebSocket({
chatId,
autoConnect = true,
reconnectInterval = 3000,
maxReconnectAttempts = 5
}: UseWebSocketOptions) {
const [isConnected, setIsConnected] = useState(false);
const [reconnectAttempts, setReconnectAttempts] = useState(0);
const wsRef = useRef<WebSocket | null>(null);
const reconnectTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const isConnectingRef = useRef(false);
const handlers = useRef<WebSocketHandlers>({
onMessage: () => {},
onTyping: () => {},
onError: () => {},
onConnect: () => {},
onDisconnect: () => {},
});
const connect = useCallback(() => {
if (!chatId || isConnectingRef.current || wsRef.current?.readyState === WebSocket.OPEN) {
return;
}
isConnectingRef.current = true;
try {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const host = process.env.NEXT_PUBLIC_WS_URL || 'localhost:9000';
// Получаем JWT токен для аутентификации WebSocket
const token = localStorage.getItem('access_token');
const wsUrl = token
? `${protocol}//${host}/ws/?token=${token}`
: `${protocol}//${host}/ws/`;
const ws = new WebSocket(wsUrl);
wsRef.current = ws;
ws.onopen = () => {
setIsConnected(true);
setReconnectAttempts(0);
isConnectingRef.current = false;
// Открываем чат на сервере
ws.send(JSON.stringify({
action: 'open_chat',
chat_id: chatId,
load_history: false
}));
handlers.current.onConnect();
};
ws.onmessage = (event) => {
try {
const data: WebSocketMessage = JSON.parse(event.data);
console.log('📨 WebSocket message:', data);
// Обработка специфичных событий
if (data.type === 'chat_user_typing' && data.user_id) {
handlers.current.onTyping(data.user_id);
}
// Общий обработчик
handlers.current.onMessage(data);
} catch (error) {
console.error('Failed to parse WebSocket message:', error);
}
};
ws.onerror = (error) => {
console.error('❌ WebSocket error:', error);
console.warn('⚠️ WebSocket не может подключиться. Убедитесь что backend запущен через Daphne:');
console.warn(' cd backend && c:/Users/igor_/Dev/EUSRR/.venv/Scripts/python -m daphne -p 9000 eusrr_backend.asgi:application');
handlers.current.onError(error);
isConnectingRef.current = false;
};
ws.onclose = () => {
setIsConnected(false);
isConnectingRef.current = false;
handlers.current.onDisconnect();
// Автоматическое переподключение
if (autoConnect && reconnectAttempts < maxReconnectAttempts) {
console.log(`🔄 Reconnecting... (attempt ${reconnectAttempts + 1}/${maxReconnectAttempts})`);
reconnectTimeoutRef.current = setTimeout(() => {
setReconnectAttempts(prev => prev + 1);
connect();
}, reconnectInterval);
} else if (reconnectAttempts >= maxReconnectAttempts) {
console.error('❌ Max reconnection attempts reached. Backend may not support WebSocket.');
console.warn('⚠️ Запустите backend через Daphne для WebSocket:');
console.warn(' cd C:/Users/igor_/Dev/EUSRR/backend');
console.warn(' c:/Users/igor_/Dev/EUSRR/.venv/Scripts/python -m daphne -p 9000 eusrr_backend.asgi:application');
}
};
} catch (error) {
console.error('Failed to create WebSocket:', error);
isConnectingRef.current = false;
}
}, [chatId, autoConnect, reconnectInterval, maxReconnectAttempts, reconnectAttempts]);
const disconnect = useCallback(() => {
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current);
reconnectTimeoutRef.current = null;
}
if (wsRef.current) {
const ws = wsRef.current;
if (ws.readyState === WebSocket.OPEN && chatId) {
ws.send(JSON.stringify({
action: 'close_chat',
chat_id: chatId
}));
}
ws.close();
wsRef.current = null;
}
setIsConnected(false);
setReconnectAttempts(0);
isConnectingRef.current = false;
}, [chatId]);
const sendTyping = useCallback(() => {
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(JSON.stringify({
action: 'typing'
}));
}
}, []);
const sendMessage = useCallback((message: any) => {
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(JSON.stringify(message));
} else {
console.warn('WebSocket is not connected. Cannot send message.');
}
}, []);
// Подключение при монтировании или изменении chatId
useEffect(() => {
if (autoConnect && chatId) {
connect();
}
return () => {
disconnect();
};
}, [chatId, autoConnect]);
// Очистка при размонтировании
useEffect(() => {
return () => {
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current);
}
};
}, []);
return {
isConnected,
reconnectAttempts,
handlers,
connect,
disconnect,
sendTyping,
sendMessage,
};
}

View File

@ -303,20 +303,49 @@ class ApiClient {
}
async sendMessage(chatId: number | string, text: string, replyTo?: number): Promise<any> {
return this.request(`/api/v1/communications/chats/${chatId}/messages/`, {
const formData = new FormData();
formData.append('content', text);
formData.append('chat_id', chatId.toString());
if (replyTo) {
formData.append('reply_to', replyTo.toString());
}
const token = this.getToken();
const headers: Record<string, string> = {};
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
const response = await fetch(`/api/v1/communications/messages/upload/`, {
method: 'POST',
body: JSON.stringify({ text, reply_to: replyTo }),
headers,
body: formData,
});
if (!response.ok) {
let errorDetails = '';
try {
const errorData = await response.json();
errorDetails = errorData.detail || JSON.stringify(errorData);
} catch {
errorDetails = response.statusText;
}
throw new Error(`API Error: ${response.status} ${errorDetails}`);
}
const result = await response.json();
return result.message || result;
}
async sendMessageWithFiles(chatId: number | string, text: string, files: File[], replyTo?: number): Promise<any> {
const formData = new FormData();
formData.append('text', text);
formData.append('content', text);
formData.append('chat_id', chatId.toString());
if (replyTo) {
formData.append('reply_to', replyTo.toString());
}
files.forEach((file) => {
formData.append('attachments', file);
files.forEach((file, index) => {
formData.append(`file_${index}`, file);
});
const token = this.getToken();
@ -325,23 +354,31 @@ class ApiClient {
headers['Authorization'] = `Bearer ${token}`;
}
const response = await fetch(`/api/v1/communications/chats/${chatId}/messages/`, {
const response = await fetch(`/api/v1/communications/messages/upload/`, {
method: 'POST',
headers,
body: formData,
});
if (!response.ok) {
throw new Error(`API Error: ${response.status} ${response.statusText}`);
let errorDetails = '';
try {
const errorData = await response.json();
errorDetails = errorData.detail || JSON.stringify(errorData);
} catch {
errorDetails = response.statusText;
}
throw new Error(`API Error: ${response.status} ${errorDetails}`);
}
return response.json();
const result = await response.json();
return result.message || result;
}
async updateMessage(messageId: number, text: string): Promise<any> {
return this.request(`/api/v1/communications/messages/${messageId}/`, {
method: 'PATCH',
body: JSON.stringify({ text }),
body: JSON.stringify({ content: text }),
});
}
@ -832,84 +869,14 @@ class ApiClient {
return this.request(`/api/v1/communications/chats/${chatId}/messages/${query ? '?' + query : ''}`);
}
/**
* Отправить текстовое сообщение
*/
async sendMessage(
chatId: number,
content: string,
replyTo?: number
): Promise<any> {
return this.request('/api/v1/communications/messages/', {
method: 'POST',
body: JSON.stringify({
chat_id: chatId,
content,
reply_to: replyTo,
}),
});
}
/**
* Отправить сообщение с файлами
*/
async sendMessageWithFiles(
chatId: number,
content: string,
files: File[],
replyTo?: number
): Promise<any> {
const formData = new FormData();
formData.append('chat_id', chatId.toString());
formData.append('content', content);
if (replyTo) formData.append('reply_to', replyTo.toString());
// Добавляем файлы с префиксом file_
files.forEach((file, index) => {
formData.append(`file_${index}`, file);
});
const token = this.getToken();
const response = await fetch('/api/v1/communications/messages/upload/', {
method: 'POST',
headers: token ? { 'Authorization': `Bearer ${token}` } : {},
body: formData,
});
if (!response.ok) {
throw new Error(`API Error: ${response.status} ${response.statusText}`);
}
return response.json();
}
/**
* Редактировать сообщение
*/
async updateMessage(messageId: number, content: string): Promise<any> {
return this.request(`/api/v1/communications/messages/${messageId}/`, {
method: 'PATCH',
body: JSON.stringify({ content }),
});
}
/**
* Удалить сообщение
*/
async deleteMessage(messageId: number): Promise<void> {
await this.request(`/api/v1/communications/messages/${messageId}/`, {
method: 'DELETE',
});
}
/**
* Пометить чат как прочитанный
*/
async markChatAsRead(chatId: number, lastReadMessageId?: number): Promise<any> {
return this.request(`/api/v1/communications/chats/${chatId}/mark_read/`, {
return this.request(`/api/v1/communications/chats/${chatId}/mark-read/`, {
method: 'POST',
body: JSON.stringify({
last_read_message_id: lastReadMessageId
message_id: lastReadMessageId
}),
});
}