From bc20d7e61ec69df6304c9bad2128d5ed72b8af18 Mon Sep 17 00:00:00 2001 From: igor_nadein Date: Wed, 25 Feb 2026 20:29:00 +0300 Subject: [PATCH] =?UTF-8?q?fix:=20=D0=98=D1=81=D0=BF=D1=80=D0=B0=D0=B2?= =?UTF-8?q?=D0=BB=D0=B5=D0=BD=D0=B0=20=D1=81=D0=B8=D1=81=D1=82=D0=B5=D0=BC?= =?UTF-8?q?=D0=B0=20=D0=B0=D0=B2=D1=82=D0=BE=D0=BE=D1=82=D0=BC=D0=B5=D1=82?= =?UTF-8?q?=D0=BA=D0=B8=20=D0=BF=D1=80=D0=BE=D1=87=D0=B8=D1=82=D0=B0=D0=BD?= =?UTF-8?q?=D0=BD=D1=8B=D1=85=20=D1=81=D0=BE=D0=BE=D0=B1=D1=89=D0=B5=D0=BD?= =?UTF-8?q?=D0=B8=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Удалено поле 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) --- backend/api/v1/communications/serializers.py | 30 +- backend/api/v1/communications/views.py | 267 +++++++----- backend/communications/context_processors.py | 15 +- ...8_remove_last_read_at_and_unused_fields.py | 31 ++ ...hatreadstate_last_read_message_and_more.py | 25 ++ backend/communications/models.py | 82 ++-- backend/communications/views.py | 69 ++- .../AUTO_MARK_READ_IMPLEMENTATION.md | 309 ++++++++++++++ backend/realtime/consumers.py | 68 +-- docs/guides/WEBSOCKET_BACKEND_SETUP.md | 85 ++++ .../in-progress/MESSAGES_IMPROVEMENTS_PLAN.md | 301 +++++++++++++ frontend/src/app/messages/[chatId]/page.tsx | 396 ++++++++++++++---- .../src/components/ScrollableMessageList.tsx | 379 +++++++++++++++++ frontend/src/hooks/useWebSocket.ts | 201 +++++++++ frontend/src/lib/api.ts | 129 +++--- 15 files changed, 2008 insertions(+), 379 deletions(-) create mode 100644 backend/communications/migrations/0028_remove_last_read_at_and_unused_fields.py create mode 100644 backend/communications/migrations/0029_alter_chatreadstate_last_read_message_and_more.py create mode 100644 backend/docs/completed/AUTO_MARK_READ_IMPLEMENTATION.md create mode 100644 docs/guides/WEBSOCKET_BACKEND_SETUP.md create mode 100644 docs/in-progress/MESSAGES_IMPROVEMENTS_PLAN.md create mode 100644 frontend/src/components/ScrollableMessageList.tsx create mode 100644 frontend/src/hooks/useWebSocket.ts diff --git a/backend/api/v1/communications/serializers.py b/backend/api/v1/communications/serializers.py index 9740b27e..9b86288d 100644 --- a/backend/api/v1/communications/serializers.py +++ b/backend/api/v1/communications/serializers.py @@ -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): diff --git a/backend/api/v1/communications/views.py b/backend/api/v1/communications/views.py index a1e5627e..10417bb9 100644 --- a/backend/api/v1/communications/views.py +++ b/backend/api/v1/communications/views.py @@ -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): diff --git a/backend/communications/context_processors.py b/backend/communications/context_processors.py index 2f74a5f3..2ed12500 100644 --- a/backend/communications/context_processors.py +++ b/backend/communications/context_processors.py @@ -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, ) diff --git a/backend/communications/migrations/0028_remove_last_read_at_and_unused_fields.py b/backend/communications/migrations/0028_remove_last_read_at_and_unused_fields.py new file mode 100644 index 00000000..e4fcc24d --- /dev/null +++ b/backend/communications/migrations/0028_remove_last_read_at_and_unused_fields.py @@ -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', + ), + ] diff --git a/backend/communications/migrations/0029_alter_chatreadstate_last_read_message_and_more.py b/backend/communications/migrations/0029_alter_chatreadstate_last_read_message_and_more.py new file mode 100644 index 00000000..ce48f36e --- /dev/null +++ b/backend/communications/migrations/0029_alter_chatreadstate_last_read_message_and_more.py @@ -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'), + ), + ] diff --git a/backend/communications/models.py b/backend/communications/models.py index 0d968dfe..d6e6a9d1 100644 --- a/backend/communications/models.py +++ b/backend/communications/models.py @@ -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 diff --git a/backend/communications/views.py b/backend/communications/views.py index 31fed970..69a0a901 100644 --- a/backend/communications/views.py +++ b/backend/communications/views.py @@ -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)) diff --git a/backend/docs/completed/AUTO_MARK_READ_IMPLEMENTATION.md b/backend/docs/completed/AUTO_MARK_READ_IMPLEMENTATION.md new file mode 100644 index 00000000..8dbe8806 --- /dev/null +++ b/backend/docs/completed/AUTO_MARK_READ_IMPLEMENTATION.md @@ -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 строк кода diff --git a/backend/realtime/consumers.py b/backend/realtime/consumers.py index b541b4cd..92246f7d 100644 --- a/backend/realtime/consumers.py +++ b/backend/realtime/consumers.py @@ -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): diff --git a/docs/guides/WEBSOCKET_BACKEND_SETUP.md b/docs/guides/WEBSOCKET_BACKEND_SETUP.md new file mode 100644 index 00000000..1aed5267 --- /dev/null +++ b/docs/guides/WEBSOCKET_BACKEND_SETUP.md @@ -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 diff --git a/docs/in-progress/MESSAGES_IMPROVEMENTS_PLAN.md b/docs/in-progress/MESSAGES_IMPROVEMENTS_PLAN.md new file mode 100644 index 00000000..481d7d24 --- /dev/null +++ b/docs/in-progress/MESSAGES_IMPROVEMENTS_PLAN.md @@ -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 && } +``` + +### ✅ 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(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) => { + setMessageText(e.target.value); + sendTyping(); // Отправляем индикатор + }; + + // UI индикатора + {typing && ( +
+ Собеседник печатает... +
+ )} +} +``` + +--- + +## Риски и меры предосторожности + +### ⚠️ Риск: Дублирование сообщений +**Решение:** Использовать `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 для файлов +- 🖼️ Галерея медиа из чата +- 🔍 Поиск по сообщениям +- 📌 Закрепленные сообщения +- 🔊 Звуковые уведомления о новых сообщениях +- 💾 Офлайн режим с кешированием +- ⚡ Оптимизация производительности для больших чатов (виртуализация списка) diff --git a/frontend/src/app/messages/[chatId]/page.tsx b/frontend/src/app/messages/[chatId]/page.tsx index 00694244..c098fa12 100644 --- a/frontend/src/app/messages/[chatId]/page.tsx +++ b/frontend/src/app/messages/[chatId]/page.tsx @@ -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(null); const messageInputRef = useRef(null); - const messagesViewportRef = useRef(null); + const messagesViewportRef = useRef(null); const [hasMoreOlder, setHasMoreOlder] = useState(false); const [hasMoreNewer, setHasMoreNewer] = useState(false); const [loadingOlder, setLoadingOlder] = useState(false); const [loadingNewer, setLoadingNewer] = useState(false); const [initialAnchorId, setInitialAnchorId] = useState(null); + const [isTyping, setIsTyping] = useState(false); + const typingTimeoutRef = useRef(null); const [initialAnchorIndex, setInitialAnchorIndex] = useState(null); const [allowOneOlderProbe, setAllowOneOlderProbe] = useState(false); const initialScrolledRef = useRef(false); const anchorScrollAttemptsRef = useRef(0); const anchorOlderLoadAttemptsRef = useRef(0); - const 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 + ) => { + 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 - ) => { - 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() { ) : (
+ {/* Предупреждение о WebSocket */} + {!isConnected && reconnectAttempts > 2 && ( +
+
+
+

⚠️ Real-time обновления отключены

+

+ Backend не поддерживает WebSocket. Запустите через Daphne: +

+ + cd backend && ..\\.venv\\Scripts\\daphne -p 9000 eusrr_backend.asgi:application + +
+ +
+
+ )} +
{chat ? ( <> @@ -889,7 +1087,13 @@ export default function MessageDialogPage() { )}
-

{getChatTitle(chat, user?.id)}

+
+

{getChatTitle(chat, user?.id)}

+ {/* Индикатор WebSocket соединения */} + {isConnected && ( + + )} +

{(chat.type || chat.chat_type) === "group" ? "Групповой чат" : "Диалог"}

@@ -905,10 +1109,12 @@ export default function MessageDialogPage() {
-
{messagesLoading ? (

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

@@ -1060,6 +1266,8 @@ export default function MessageDialogPage() { {att.file_name} { 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(); + }} /> ) @@ -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(); + }} > @@ -1179,9 +1401,21 @@ export default function MessageDialogPage() { })}
)} -
+
+ {/* Индикатор "печатает..." */} + {isTyping && ( +
+
+ + + +
+ Собеседник печатает... +
+ )} + {editingMessageId ? (
@@ -1289,7 +1523,11 @@ export default function MessageDialogPage() {