diff --git a/backend/api/v1/communications/viewsets.py b/backend/api/v1/communications/viewsets.py index d13220f6..e1b08cbd 100644 --- a/backend/api/v1/communications/viewsets.py +++ b/backend/api/v1/communications/viewsets.py @@ -335,24 +335,49 @@ class ChatViewSet(viewsets.ModelViewSet): """ 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}") + 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 - read_state.last_read_at = timezone.datetime.fromtimestamp(ts_value, tz=timezone.utc) - except (ValueError, OSError): - read_state.last_read_at = timezone.now() + 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: - read_state.last_read_at = timezone.now() + 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 = request.data.get('message_id') @@ -360,8 +385,9 @@ class ChatViewSet(viewsets.ModelViewSet): try: message = chat.messages.get(pk=int(message_id)) read_state.last_read_message = message - except (Message.DoesNotExist, ValueError): - pass + logger.info(f"[mark_read] Set last_read_message from request: {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: @@ -371,9 +397,27 @@ class ChatViewSet(viewsets.ModelViewSet): ).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}") + else: + logger.warning(f"[mark_read] No messages found before {read_state.last_read_at}") 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, + } + ) + 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, @@ -514,6 +558,44 @@ class MessageViewSet(viewsets.ModelViewSet): 'message': payload }, status=status.HTTP_201_CREATED) + @action(detail=False, methods=['post'], url_path='upload-temp') + def upload_temp(self, request): + """ + Временная загрузка файлов для редактирования сообщения + POST /api/v1/communications/messages/upload-temp/ + Создает MessageAttachment без привязки к сообщению (message=null) + """ + # Получаем файлы + files = [] + for key in request.FILES: + if key.startswith('file_'): + files.append(request.FILES[key]) + + if not files: + return Response( + {'error': 'No files provided'}, + status=status.HTTP_400_BAD_REQUEST + ) + + # Создаем attachments БЕЗ привязки к сообщению + attachment_ids = [] + with transaction.atomic(): + for file in files: + attachment = MessageAttachment.objects.create( + message=None, # Без сообщения! + file=file, + file_name=file.name, + file_size=file.size, + mime_type=file.content_type or 'application/octet-stream', + file_type=self._detect_file_type(file.content_type) + ) + attachment_ids.append(attachment.id) + + return Response({ + 'ok': True, + 'attachment_ids': attachment_ids + }, status=status.HTTP_201_CREATED) + def update(self, request, *args, **kwargs): """Редактирование сообщения""" partial = kwargs.pop('partial', False) @@ -570,13 +652,23 @@ class MessageViewSet(viewsets.ModelViewSet): current_ids = set(str(att.id) for att in instance.attachments.all()) keep_ids = set(str(id) for id in existing_attachment_ids) ids_to_remove = current_ids - keep_ids + ids_to_add = keep_ids - current_ids + # Удаляем attachments которых нет в списке if ids_to_remove: attachments_to_delete = instance.attachments.filter(id__in=ids_to_remove) for att in attachments_to_delete: if att.file and att.file.storage.exists(att.file.name): att.file.delete(save=False) attachments_to_delete.delete() + logger.info(f"[update] Removed attachments: {ids_to_remove}") + + # Переносим новые attachments из других сообщений к этому + if ids_to_add: + from communications.models import MessageAttachment + attachments_to_move = MessageAttachment.objects.filter(id__in=ids_to_add) + updated_count = attachments_to_move.update(message=instance) + logger.info(f"[update] Moved {updated_count} attachments to message {instance.id}: {list(ids_to_add)}") instance.save() @@ -584,7 +676,7 @@ class MessageViewSet(viewsets.ModelViewSet): instance.has_attachments = instance.attachments.count() > 0 instance.save(update_fields=['has_attachments']) - # Перезагружаем + # Перезагружаем с нуля чтобы получить обновленные attachments instance = Message.objects.select_related( 'author', 'reply_to', 'reply_to__author', 'poll' ).prefetch_related( @@ -595,8 +687,10 @@ class MessageViewSet(viewsets.ModelViewSet): channel_layer = get_channel_layer() payload = serialize_message(instance) + group_name = f'chat_{instance.chat_id}' + async_to_sync(channel_layer.group_send)( - f'chat_{instance.chat_id}', + group_name, { 'type': 'chat.message_edited', 'chat_id': instance.chat_id, @@ -638,10 +732,13 @@ class MessageViewSet(viewsets.ModelViewSet): def react(self, request, pk=None): """Добавить реакцию""" message = self.get_object() + logger.info(f"[react] User {request.user.id} adding reaction to message {message.id}") + serializer = ReactionSerializer(data=request.data) serializer.is_valid(raise_exception=True) emoji = serializer.validated_data['emoji'] + logger.info(f"[react] Emoji: {emoji}") # Создаем или получаем реакцию reaction, created = MessageReaction.objects.get_or_create( @@ -650,6 +747,8 @@ class MessageViewSet(viewsets.ModelViewSet): emoji=emoji ) + logger.info(f"[react] Reaction created={created}, id={reaction.id}") + if not created: return Response( {'error': 'Reaction already exists'}, @@ -669,12 +768,17 @@ class MessageViewSet(viewsets.ModelViewSet): reactions_summary[r.emoji]['users'].append(r.user_id) reactions_summary[r.emoji]['user_names'].append(r.user.get_full_name()) + logger.info(f"[react] reactions_summary: {reactions_summary}") + # WebSocket уведомление channel_layer = get_channel_layer() + logger.info(f"[react] Sending to chat_{message.chat_id} group") + async_to_sync(channel_layer.group_send)( f'chat_{message.chat_id}', { 'type': 'chat.reaction_added', + 'chat_id': message.chat_id, 'message_id': message.id, 'user_id': request.user.id, 'emoji': emoji, @@ -682,16 +786,21 @@ class MessageViewSet(viewsets.ModelViewSet): } ) + logger.info(f"[react] WebSocket message sent successfully") + return Response({'ok': True, 'reactions_summary': reactions_summary}) @action(detail=True, methods=['post']) def unreact(self, request, pk=None): """Убрать реакцию""" message = self.get_object() + logger.info(f"[unreact] User {request.user.id} removing reaction from message {message.id}") + serializer = ReactionSerializer(data=request.data) serializer.is_valid(raise_exception=True) emoji = serializer.validated_data['emoji'] + logger.info(f"[unreact] Emoji: {emoji}") deleted_count = MessageReaction.objects.filter( message=message, @@ -699,6 +808,8 @@ class MessageViewSet(viewsets.ModelViewSet): emoji=emoji ).delete()[0] + logger.info(f"[unreact] Deleted count: {deleted_count}") + if deleted_count == 0: return Response( {'error': 'Reaction not found'}, @@ -718,12 +829,17 @@ class MessageViewSet(viewsets.ModelViewSet): reactions_summary[r.emoji]['users'].append(r.user_id) reactions_summary[r.emoji]['user_names'].append(r.user.get_full_name()) + logger.info(f"[unreact] reactions_summary: {reactions_summary}") + # WebSocket уведомление channel_layer = get_channel_layer() + logger.info(f"[unreact] Sending to chat_{message.chat_id} group") + async_to_sync(channel_layer.group_send)( f'chat_{message.chat_id}', { 'type': 'chat.reaction_removed', + 'chat_id': message.chat_id, 'message_id': message.id, 'user_id': request.user.id, 'emoji': emoji, @@ -731,6 +847,8 @@ class MessageViewSet(viewsets.ModelViewSet): } ) + logger.info(f"[unreact] WebSocket message sent successfully") + return Response({'ok': True, 'reactions_summary': reactions_summary}) @action(detail=False, methods=['post']) diff --git a/backend/communications/consumers.py b/backend/communications/consumers.py index ce030029..e329fc3d 100644 --- a/backend/communications/consumers.py +++ b/backend/communications/consumers.py @@ -492,6 +492,7 @@ class ChatConsumer(AsyncJsonWebsocketConsumer): async def chat_reaction_added(self, event): """Реакция добавлена""" + logger.info(f"[Consumer] chat_reaction_added called: message_id={event.get('message_id')}, emoji={event.get('emoji')}") await self.send_json({ "type": "reaction_added", "message_id": event["message_id"], @@ -499,9 +500,11 @@ class ChatConsumer(AsyncJsonWebsocketConsumer): "emoji": event.get("emoji"), "reactions_summary": event.get("reactions_summary", {}) }) + logger.info(f"[Consumer] reaction_added sent to client") async def chat_reaction_removed(self, event): """Реакция удалена""" + logger.info(f"[Consumer] chat_reaction_removed called: message_id={event.get('message_id')}, emoji={event.get('emoji')}") await self.send_json({ "type": "reaction_removed", "message_id": event["message_id"], @@ -509,6 +512,7 @@ class ChatConsumer(AsyncJsonWebsocketConsumer): "emoji": event.get("emoji"), "reactions_summary": event.get("reactions_summary", {}) }) + logger.info(f"[Consumer] reaction_removed sent to client") async def chat_user_typing(self, event): """Пользователь набирает текст""" diff --git a/backend/communications/migrations/0022_alter_messageattachment_height_and_more.py b/backend/communications/migrations/0022_alter_messageattachment_height_and_more.py new file mode 100644 index 00000000..34af5ee9 --- /dev/null +++ b/backend/communications/migrations/0022_alter_messageattachment_height_and_more.py @@ -0,0 +1,29 @@ +# Generated by Django 5.2.4 on 2026-01-15 08:06 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('communications', '0021_add_image_dimensions'), + ] + + operations = [ + migrations.AlterField( + model_name='messageattachment', + name='height', + field=models.IntegerField(blank=True, help_text='Высота изображения или видео в пикселях', null=True, verbose_name='Высота'), + ), + migrations.AlterField( + model_name='messageattachment', + name='message', + field=models.ForeignKey(blank=True, help_text='Может быть null для временных вложений при редактировании', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='attachments', to='communications.message', verbose_name='Сообщение'), + ), + migrations.AlterField( + model_name='messageattachment', + name='width', + field=models.IntegerField(blank=True, help_text='Ширина изображения или видео в пикселях', null=True, verbose_name='Ширина'), + ), + ] diff --git a/backend/communications/models.py b/backend/communications/models.py index 5b97fbcf..179f20dc 100644 --- a/backend/communications/models.py +++ b/backend/communications/models.py @@ -529,7 +529,10 @@ class MessageAttachment(models.Model): Message, on_delete=models.CASCADE, related_name='attachments', - verbose_name="Сообщение" + verbose_name="Сообщение", + null=True, + blank=True, + help_text="Может быть null для временных вложений при редактировании" ) file = models.FileField( upload_to='chat_attachments/%Y/%m/%d/', diff --git a/backend/communications/tasks.py b/backend/communications/tasks.py new file mode 100644 index 00000000..b88fc6e7 --- /dev/null +++ b/backend/communications/tasks.py @@ -0,0 +1,43 @@ +""" +Celery задачи для модуля communications +""" +from celery import shared_task +from django.utils import timezone +from datetime import timedelta +import logging + +logger = logging.getLogger(__name__) + + +@shared_task +def cleanup_orphaned_attachments(): + """ + Очистка временных вложений без привязки к сообщению. + Удаляет MessageAttachment с message=null старше 1 часа. + """ + from communications.models import MessageAttachment + + cutoff_time = timezone.now() - timedelta(hours=1) + + orphaned = MessageAttachment.objects.filter( + message__isnull=True, + created_at__lt=cutoff_time + ) + + count = orphaned.count() + + if count > 0: + logger.info(f'[cleanup_orphaned_attachments] Found {count} orphaned attachments') + + # Удаляем файлы и записи + for attachment in orphaned: + try: + if attachment.file: + attachment.file.delete() + attachment.delete() + except Exception as e: + logger.error(f'[cleanup_orphaned_attachments] Error deleting attachment {attachment.id}: {e}') + + logger.info(f'[cleanup_orphaned_attachments] Cleaned up {count} orphaned attachments') + + return count diff --git a/backend/eusrr_backend/settings.py b/backend/eusrr_backend/settings.py index d25df8b6..296abb7b 100644 --- a/backend/eusrr_backend/settings.py +++ b/backend/eusrr_backend/settings.py @@ -212,6 +212,7 @@ LOGGING = { "documents": {"handlers": ["console"], "level": "DEBUG", "propagate": False}, "bots": {"handlers": ["console"], "level": "DEBUG", "propagate": False}, "communications": {"handlers": ["console"], "level": "DEBUG", "propagate": False}, + "api.v1.communications": {"handlers": ["console"], "level": "DEBUG", "propagate": False}, "notifications": {"handlers": ["console"], "level": "INFO", "propagate": False}, "requests_app": {"handlers": ["console"], "level": "DEBUG", "propagate": False}, "employees": {"handlers": ["console"], "level": "INFO", "propagate": False}, @@ -496,6 +497,14 @@ CELERY_TASK_ROUTES = { # Настройки для периодических задач (Celery Beat) CELERY_BEAT_SCHEDULER = 'django_celery_beat.schedulers:DatabaseScheduler' +# Расписание периодических задач +CELERY_BEAT_SCHEDULE = { + 'cleanup-orphaned-attachments': { + 'task': 'communications.tasks.cleanup_orphaned_attachments', + 'schedule': 3600.0, # Каждый час + }, +} + # Опции по умолчанию для всех задач CELERY_TASK_ACKS_LATE = True # Подтверждаем выполнение после завершения CELERY_WORKER_PREFETCH_MULTIPLIER = 1 # Берем по 1 задаче за раз diff --git a/backend/realtime/consumers.py b/backend/realtime/consumers.py index 819dbac3..1b1ffbbe 100644 --- a/backend/realtime/consumers.py +++ b/backend/realtime/consumers.py @@ -9,6 +9,7 @@ WebSocket Consumer для пользователя. - Календарь и другие real-time события """ import asyncio +import logging from channels.db import database_sync_to_async from channels.generic.websocket import AsyncJsonWebsocketConsumer from django.contrib.auth.models import AnonymousUser @@ -18,6 +19,8 @@ from django.utils import timezone from communications.models import Chat, ChatMembership, ChatReadState, Message, MessageReaction from communications.consumers import serialize_message +logger = logging.getLogger(__name__) + class UserConsumer(AsyncJsonWebsocketConsumer): """ @@ -299,9 +302,14 @@ class UserConsumer(AsyncJsonWebsocketConsumer): async def chat_reaction_added(self, event): """Реакция добавлена""" + import logging + logger = logging.getLogger(__name__) + chat_id = event.get("chat_id") + logger.info(f"[UserConsumer] chat_reaction_added: chat_id={chat_id}, active_chat_id={self.active_chat_id}, message_id={event.get('message_id')}") if chat_id == self.active_chat_id: + logger.info(f"[UserConsumer] Sending reaction_added to client") await self.send_json({ "type": "reaction_added", "chat_id": chat_id, @@ -311,12 +319,19 @@ class UserConsumer(AsyncJsonWebsocketConsumer): "user_name": event.get("user_name"), "reactions_summary": event.get("reactions_summary") }) + else: + logger.info(f"[UserConsumer] Skipping - not active chat") async def chat_reaction_removed(self, event): """Реакция удалена""" + import logging + logger = logging.getLogger(__name__) + chat_id = event.get("chat_id") + logger.info(f"[UserConsumer] chat_reaction_removed: chat_id={chat_id}, active_chat_id={self.active_chat_id}, message_id={event.get('message_id')}") if chat_id == self.active_chat_id: + logger.info(f"[UserConsumer] Sending reaction_removed to client") await self.send_json({ "type": "reaction_removed", "chat_id": chat_id, @@ -325,6 +340,8 @@ class UserConsumer(AsyncJsonWebsocketConsumer): "user_id": event.get("user_id"), "reactions_summary": event.get("reactions_summary") }) + else: + logger.info(f"[UserConsumer] Skipping - not active chat") async def chat_user_typing(self, event): """Пользователь печатает""" @@ -366,6 +383,21 @@ class UserConsumer(AsyncJsonWebsocketConsumer): "results": payload.get("results") }) + async def chat_marked_read(self, event): + """Синхронизация отметки прочитанного между вкладками""" + 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}") + + 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 + }) + # ==================== Обработчики уведомлений ==================== async def notification_new(self, event): @@ -591,12 +623,19 @@ class UserConsumer(AsyncJsonWebsocketConsumer): async def _handle_mark_read(self, content): """Отметка сообщений как прочитанных""" + 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") return + logger.info(f"[Consumer._handle_mark_read] User {self.user.id} marking chat {chat_id} as read") + 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) @@ -735,11 +774,25 @@ class UserConsumer(AsyncJsonWebsocketConsumer): @database_sync_to_async def _mark_read(self, chat, user): """Отметить чат как прочитанный""" - ChatReadState.objects.update_or_create( + 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.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}") @database_sync_to_async def _set_typing_status(self, chat, user, is_typing): diff --git a/backend/static/js/components/chatComposer.js b/backend/static/js/components/chatComposer.js index f29829fb..0d4a3fff 100644 --- a/backend/static/js/components/chatComposer.js +++ b/backend/static/js/components/chatComposer.js @@ -416,15 +416,13 @@ class ChatComposer { console.log('[ChatComposer] Uploading new files:', newFiles.length); const formData = new FormData(); - formData.set('chat_id', this.chatId); - formData.set('content', ''); // Пустое сообщение для загрузки файлов newFiles.forEach((entry, index) => { formData.append(`file_${index}`, entry.file, entry.file.name); }); - // Загружаем файлы через upload-message - const uploadResponse = await fetch(this.uploadUrl, { + // Загружаем файлы через временный endpoint (без создания сообщения) + const uploadResponse = await fetch('/api/v1/communications/messages/upload-temp/', { method: 'POST', headers: { 'X-CSRFToken': csrfToken @@ -439,24 +437,10 @@ class ChatComposer { const uploadData = await uploadResponse.json(); - // Получаем ID временного сообщения и его вложений - if (uploadData.message && uploadData.message.attachments) { - uploadData.message.attachments.forEach(att => { - newAttachmentIds.push(att.id); - }); - + // Получаем ID вложений без привязки к сообщению + if (uploadData.attachment_ids) { + newAttachmentIds.push(...uploadData.attachment_ids); console.log('[ChatComposer] New attachments uploaded:', newAttachmentIds); - - // Удаляем временное сообщение - if (uploadData.message.id) { - await fetch(`/api/v1/communications/messages/${uploadData.message.id}/`, { - method: 'DELETE', - headers: { - 'Content-Type': 'application/json', - 'X-CSRFToken': csrfToken - } - }); - } } } diff --git a/backend/static/js/components/chatMarkRead.js b/backend/static/js/components/chatMarkRead.js index 0b30a985..4827643b 100644 --- a/backend/static/js/components/chatMarkRead.js +++ b/backend/static/js/components/chatMarkRead.js @@ -21,6 +21,8 @@ import { getCookie } from '../utils/stringUtils.js'; * @returns {Object|null} API обработчика или null если элементы не найдены */ export function initChatMarkRead(options = {}) { + console.log('[ChatMarkRead] Initializing with options:', options); + const { scrollContainerId = 'chatScroll', textareaId = 'id_content', @@ -34,16 +36,20 @@ export function initChatMarkRead(options = {}) { // Проверка обязательных параметров if (!chatId || !meId) { - console.warn('ChatMarkRead: chatId and meId are required'); + console.warn('[ChatMarkRead] chatId and meId are required', { chatId, meId }); return null; } // Получение элементов const box = document.getElementById(scrollContainerId); if (!box) { - console.warn(`ChatMarkRead: element #${scrollContainerId} not found`); + console.warn(`[ChatMarkRead] element #${scrollContainerId} not found`); return null; } + + // Установка data-me-id для markVisibleMessagesAsRead + box.dataset.meId = String(meId); + console.log('[ChatMarkRead] Set data-me-id on chatScroll:', box.dataset.meId); const ta = document.getElementById(textareaId); const form = document.getElementById(formId); @@ -188,12 +194,19 @@ export function initChatMarkRead(options = {}) { */ async function markRead(ts) { ts = Number(ts); - if (!Number.isFinite(ts) || ts <= lastMarkedTs) return; + console.log('[ChatMarkRead] markRead called:', { ts, lastMarkedTs, isFinite: Number.isFinite(ts), willSkip: !Number.isFinite(ts) || ts <= lastMarkedTs }); + + if (!Number.isFinite(ts) || ts <= lastMarkedTs) { + console.log('[ChatMarkRead] Skipping markRead - invalid or already marked'); + return; + } lastMarkedTs = ts; setLocalLastReadTs(ts); box.dataset.lastReadTs = String(ts); removeUnreadDivider(); + + console.log('[ChatMarkRead] Updated local state:', { lastMarkedTs, localStorage: getLocalLastReadTs(), dataset: box.dataset.lastReadTs }); // Определение URL для отметки let url = markReadUrl; @@ -203,9 +216,11 @@ export function initChatMarkRead(options = {}) { : (location.pathname + "/"); url = base + "mark-read/"; } + + console.log('[ChatMarkRead] Sending mark-read request:', { url, chatId, upto_ts: ts }); try { - await fetch(url, { + const response = await fetch(url, { method: 'POST', headers: { 'X-CSRFToken': csrf, @@ -215,13 +230,16 @@ export function initChatMarkRead(options = {}) { body: new URLSearchParams({ upto_ts: String(ts) }), credentials: 'same-origin' }); + + const data = await response.json(); + console.log('[ChatMarkRead] mark-read response:', { status: response.status, data }); // Отправляем событие для обновления глобального бейджа window.dispatchEvent(new CustomEvent('chat:read', { detail: { chatId: String(chatId) } })); } catch (err) { - console.warn('ChatMarkRead: failed to mark as read', err); + console.error('[ChatMarkRead] Failed to mark as read:', err); } } @@ -325,6 +343,30 @@ export function initChatMarkRead(options = {}) { } }; + // Переподключение observer при добавлении новых сообщений + window.addEventListener('ws:new-message', () => { + observeLastForeign(); + }); + + // Синхронизация lastMarkedTs через WebSocket (между вкладками) + window.addEventListener('ws:marked-read', (e) => { + const { chat_id, last_read_at, last_read_message_id } = e.detail; + + if (chat_id !== chatId) return; + + console.log('[ChatMarkRead] Received marked_read event:', { chat_id, last_read_at, last_read_message_id }); + + // Парсим timestamp из ISO строки + const newTimestamp = last_read_at ? new Date(last_read_at).getTime() : 0; + + // Обновляем локальное состояние только если новее + if (newTimestamp > lastMarkedTs) { + lastMarkedTs = newTimestamp; + localStorage.setItem(lsKey, String(lastMarkedTs)); + console.log('[ChatMarkRead] Updated lastMarkedTs from WebSocket:', lastMarkedTs); + } + }); + // Экспорт в window для совместимости window.__CHAT_MARK_READ__ = api; @@ -335,3 +377,71 @@ export function initChatMarkRead(options = {}) { if (typeof window !== 'undefined') { window.initChatMarkRead = initChatMarkRead; } + +/** + * Функция инициализации отметки для видимых сообщений + * Вызывается после загрузки и рендеринга начальных сообщений + */ +export function markVisibleMessagesAsRead() { + console.log('[ChatMarkRead] markVisibleMessagesAsRead called'); + + const api = window.__CHAT_MARK_READ__; + if (!api) { + console.warn('[ChatMarkRead] API not initialized, cannot mark visible'); + return; + } + + const box = document.getElementById('chatScroll'); + const meId = Number(box?.dataset.meId || 0); + + console.log('[ChatMarkRead] markVisible: checking elements', { box: !!box, meId }); + + if (!box || !meId) { + console.warn('[ChatMarkRead] Missing chatScroll or meId'); + return; + } + + const msgs = Array.from(box.querySelectorAll('.msg[data-ts][data-author-id]')); + console.log('[ChatMarkRead] markVisible: found messages', msgs.length); + + const boxRect = box.getBoundingClientRect(); + console.log('[ChatMarkRead] markVisible: viewport', { top: boxRect.top, bottom: boxRect.bottom, height: boxRect.height }); + + let foreignCount = 0; + const visibleForeignMsgs = msgs.filter(msg => { + const authorId = Number(msg.dataset.authorId); + if (authorId === meId) return false; // Пропускаем свои сообщения + + foreignCount++; + const rect = msg.getBoundingClientRect(); + // Проверяем пересечение: сообщение видимо, если хотя бы частично во viewport + const isVisible = rect.bottom > boxRect.top && rect.top < boxRect.bottom; + + console.log('[ChatMarkRead] markVisible: checking message', { + messageId: msg.dataset.messageId || msg.dataset.id, + authorId, + rect: { top: Math.round(rect.top), bottom: Math.round(rect.bottom) }, + viewport: { top: Math.round(boxRect.top), bottom: Math.round(boxRect.bottom) }, + isVisible + }); + + return isVisible; + }); + + console.log('[ChatMarkRead] markVisible: foreign/visible', { foreignCount, visibleCount: visibleForeignMsgs.length }); + + if (visibleForeignMsgs.length > 0) { + // Используем ТЕКУЩЕЕ время, а не timestamp сообщения + // (timestamp сообщения может быть из прошлого) + const ts = Date.now(); + const newestVisible = visibleForeignMsgs[visibleForeignMsgs.length - 1]; + console.log('[ChatMarkRead] Initial mark-read for visible messages:', { + messageId: newestVisible.dataset.id, + messageCreatedAt: newestVisible.dataset.ts, + usingCurrentTime: ts + }); + api.markRead(ts); + } else { + console.log('[ChatMarkRead] markVisible: no visible foreign messages to mark'); + } +} diff --git a/backend/static/js/components/messageContextMenu.js b/backend/static/js/components/messageContextMenu.js index 563296c4..1878730f 100644 --- a/backend/static/js/components/messageContextMenu.js +++ b/backend/static/js/components/messageContextMenu.js @@ -814,6 +814,19 @@ export class MessageContextMenu { throw new Error(`HTTP ${response.status}`); } + // Статус 204 = No Content, не парсим JSON + if (response.status === 204) { + console.log('[MessageContextMenu] Delete successful (204)'); + console.log('[MessageContextMenu] NOTE: DOM will be updated via WebSocket'); + + // НЕ удаляем из DOM здесь - это сделает WebSocket handler + // Так все пользователи увидят удаление в реальном времени + + this.showToast('Сообщение удалено'); + console.log('[MessageContextMenu] Toast shown'); + return; + } + const data = await response.json(); console.log('[MessageContextMenu] Response data:', data); diff --git a/backend/static/js/components/messageEditing.js b/backend/static/js/components/messageEditing.js index e417ce0c..f44a5ed5 100644 --- a/backend/static/js/components/messageEditing.js +++ b/backend/static/js/components/messageEditing.js @@ -88,23 +88,20 @@ function updateMessageInDOM(message) { return; } - // Рендерим сообщение через существующий renderer - const newMessageHtml = renderer.renderSingleMessage(message); + // Рендерим сообщение через существующий renderer (возвращает DOM элемент) + const newMessageEl = renderer.renderSingleMessage(message); - console.log('[MessageEditing] Generated HTML length:', newMessageHtml.length); - console.log('[MessageEditing] HTML contains "attachment":', newMessageHtml.includes('attachment')); - console.log('[MessageEditing] HTML contains "reply-reference":', newMessageHtml.includes('reply-reference')); - console.log('[MessageEditing] HTML sample:', newMessageHtml.substring(0, 500)); - - // Создаём временный контейнер для парсинга HTML - const tempDiv = document.createElement('div'); - tempDiv.innerHTML = newMessageHtml.trim(); - const newMessageEl = tempDiv.firstElementChild; - if (!newMessageEl) { - console.error('[MessageEditing] Failed to parse new message HTML'); + console.error('[MessageEditing] Failed to render new message element'); return; } + + console.log('[MessageEditing] Generated element:', { + tagName: newMessageEl.tagName, + className: newMessageEl.className, + hasContent: !!newMessageEl.querySelector('.message-content'), + hasAttachments: !!newMessageEl.querySelector('.message-attachments') + }); // Сохраняем позицию скролла const scrollContainer = document.getElementById('chatScroll'); diff --git a/backend/static/js/components/messageReactions.js b/backend/static/js/components/messageReactions.js index 424fb83c..853ca31c 100644 --- a/backend/static/js/components/messageReactions.js +++ b/backend/static/js/components/messageReactions.js @@ -21,8 +21,12 @@ export class MessageReactions { * Добавить реакцию на сообщение */ async addReaction(messageId, emoji) { + console.log('[MessageReactions] Adding reaction:', { messageId, emoji }); try { - const response = await fetch(`${this.apiBaseUrl}/${messageId}/react/`, { + const url = `${this.apiBaseUrl}/${messageId}/react/`; + console.log('[MessageReactions] Sending POST to:', url); + + const response = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -31,18 +35,22 @@ export class MessageReactions { body: JSON.stringify({ emoji }) }); + console.log('[MessageReactions] Response status:', response.status); + if (!response.ok) { throw new Error(`HTTP ${response.status}`); } const data = await response.json(); + console.log('[MessageReactions] Response data:', data); + if (!data.ok) { throw new Error(data.error || 'Failed to add reaction'); } return data; } catch (error) { - console.error('Error adding reaction:', error); + console.error('[MessageReactions] Error adding reaction:', error); throw error; } } @@ -50,28 +58,37 @@ export class MessageReactions { /** * Удалить свою реакцию с сообщения */ - async removeReaction(messageId) { + async removeReaction(messageId, emoji) { + console.log('[MessageReactions] Removing reaction:', { messageId, emoji }); try { - const response = await fetch(`${this.apiBaseUrl}/${messageId}/unreact/`, { + const url = `${this.apiBaseUrl}/${messageId}/unreact/`; + console.log('[MessageReactions] Sending POST to:', url); + + const response = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRFToken': this.getCsrfToken() - } + }, + body: JSON.stringify({ emoji }) }); + console.log('[MessageReactions] Response status:', response.status); + if (!response.ok) { throw new Error(`HTTP ${response.status}`); } const data = await response.json(); + console.log('[MessageReactions] Response data:', data); + if (!data.ok) { throw new Error(data.error || 'Failed to remove reaction'); } return data; } catch (error) { - console.error('Error removing reaction:', error); + console.error('[MessageReactions] Error removing reaction:', error); throw error; } } @@ -199,7 +216,7 @@ export class MessageReactions { try { if (isActive) { // Удалить свою реакцию - await this.removeReaction(messageId); + await this.removeReaction(messageId, emoji); } else { // Добавить/изменить реакцию await this.addReaction(messageId, emoji); diff --git a/backend/static/js/components/userWebSocket.js b/backend/static/js/components/userWebSocket.js index f46d864b..16cbb48f 100644 --- a/backend/static/js/components/userWebSocket.js +++ b/backend/static/js/components/userWebSocket.js @@ -84,9 +84,10 @@ export function initUserWebSocket(options = {}) { state.reconnectAttempts = 0; // Если есть активный чат на странице, открываем его + // load_history: false, так как ChatController уже загрузил сообщения через API const currentChatId = detectCurrentChatId(); if (currentChatId) { - openChat(currentChatId, true); + openChat(currentChatId, false); } }; @@ -161,10 +162,12 @@ export function initUserWebSocket(options = {}) { break; case 'reaction_added': + console.log('[UserWS] ✅ reaction_added received:', data); handleReactionAdded(data); break; case 'reaction_removed': + console.log('[UserWS] ✅ reaction_removed received:', data); handleReactionRemoved(data); break; @@ -180,6 +183,11 @@ export function initUserWebSocket(options = {}) { case 'poll_update': handlePollUpdate(data); break; + + case 'marked_read': + console.log('[UserWS] ✅ marked_read received:', data); + handleMarkedRead(data); + break; case 'error': console.error('[UserWS] Server error:', data.error); @@ -474,6 +482,20 @@ export function initUserWebSocket(options = {}) { } })); } + + function handleMarkedRead(data) { + const { chat_id, last_read_at, last_read_message_id } = data; + + console.log('[UserWS] Dispatching ws:marked-read for chat=%s', chat_id); + + window.dispatchEvent(new CustomEvent('ws:marked-read', { + detail: { + chat_id, + last_read_at, + last_read_message_id + } + })); + } function handleTypingStart(data) { const { user_name } = data; diff --git a/backend/static/js/controllers/chatControllerV2.js b/backend/static/js/controllers/chatControllerV2.js index 00497e2b..2ab7fd36 100644 --- a/backend/static/js/controllers/chatControllerV2.js +++ b/backend/static/js/controllers/chatControllerV2.js @@ -54,6 +54,12 @@ export class ChatControllerV2 { this.containerId = options.containerId || "chatScroll"; this.lastReadMessageId = options.lastReadMessageId || null; this.lastReadTimestamp = options.lastReadTimestamp || null; + + // Для корректировки скролла при загрузке изображений + this._anchorElement = null; + this._anchorOffsetTop = 0; + this._lastScrollHeight = 0; + this._imageLoadHandlers = []; // Создаем компоненты this.store = new MessageStoreV2({ currentUserId: this.currentUserId }); @@ -652,6 +658,14 @@ export class ChatControllerV2 { relativeTop, noScrollAnimation: true, }); + + // Сохраняем якорный элемент для корректировки при загрузке изображений + this._anchorElement = messageEl; + this._anchorOffsetTop = messageEl.offsetTop; + this._lastScrollHeight = this.scrollElement.scrollHeight; + + // Устанавливаем обработчики загрузки изображений + this._setupImageLoadHandlers(); return true; } @@ -662,6 +676,82 @@ export class ChatControllerV2 { ); return false; } + + /** + * Устанавливает обработчики загрузки изображений для корректировки скролла + * @private + */ + _setupImageLoadHandlers() { + if (!this._anchorElement) return; + + // Находим все изображения в чате + const images = this.scrollElement.querySelectorAll('img.chat-media--image'); + + console.log('[ChatControllerV2] Setting up image load handlers:', images.length); + + images.forEach((img) => { + if (img.complete) { + // Изображение уже загружено + return; + } + + const handler = () => { + this._adjustScrollForImageLoad(); + }; + + img.addEventListener('load', handler, { once: true }); + this._imageLoadHandlers.push({ img, handler }); + }); + } + + /** + * Корректирует скролл при загрузке изображения + * @private + */ + _adjustScrollForImageLoad() { + if (!this._anchorElement || !this._anchorElement.isConnected) { + console.log('[ChatControllerV2] Anchor element lost, stopping adjustments'); + this._cleanupImageHandlers(); + return; + } + + const currentScrollHeight = this.scrollElement.scrollHeight; + const heightChange = currentScrollHeight - this._lastScrollHeight; + + if (heightChange > 0) { + const currentAnchorOffset = this._anchorElement.offsetTop; + const anchorDrift = currentAnchorOffset - this._anchorOffsetTop; + + if (anchorDrift > 5) { // Игнорируем маленькие изменения + console.log('[ChatControllerV2] Adjusting scroll for image load:', { + heightChange, + anchorDrift, + oldScrollTop: this.scrollElement.scrollTop, + newScrollTop: this.scrollElement.scrollTop + anchorDrift + }); + + // Корректируем scrollTop чтобы якорь остался на месте + this.scrollElement.scrollTop += anchorDrift; + + // Обновляем отслеживаемые значения + this._anchorOffsetTop = currentAnchorOffset; + } + + this._lastScrollHeight = currentScrollHeight; + } + } + + /** + * Очищает обработчики загрузки изображений + * @private + */ + _cleanupImageHandlers() { + this._imageLoadHandlers.forEach(({ img, handler }) => { + img.removeEventListener('load', handler); + }); + this._imageLoadHandlers = []; + this._anchorElement = null; + } /** * Прокручивает к сообщению diff --git a/backend/static/js/pages/chatDetail.js b/backend/static/js/pages/chatDetail.js index e4b956a7..92209471 100644 --- a/backend/static/js/pages/chatDetail.js +++ b/backend/static/js/pages/chatDetail.js @@ -33,6 +33,7 @@ ChatControllerV2 = controllerModule.ChatControllerV2; initChatMarkRead = markReadModule.initChatMarkRead; + window.markVisibleMessagesAsRead = markReadModule.markVisibleMessagesAsRead; initChatComposer = composerModule.initChatComposer; initChatFormManager = formManagerModule.initChatFormManager; @@ -170,6 +171,13 @@ initialLastReadTs: config.lastReadMessageId }); + // Отметить видимые сообщения как прочитанные после инициализации + setTimeout(() => { + if (window.markVisibleMessagesAsRead) { + window.markVisibleMessagesAsRead(); + } + }, 500); + // 4. Composer (отправка сообщений) const composer = initChatComposer({ chatId: config.chatId, @@ -184,6 +192,8 @@ // 5. Экспорт в window window.chatController = chatController; + // Добавляем прямой доступ к renderer для messageEditing.js + window.chatController.messageRenderer = chatController.renderer; window.chatComposer = composer; window.markReadApi = markReadApi; window.chatFormManager = formManager; diff --git a/backend/static/js/renderers/messageRendererV2.js b/backend/static/js/renderers/messageRendererV2.js index b75e3ef4..cb278885 100644 --- a/backend/static/js/renderers/messageRendererV2.js +++ b/backend/static/js/renderers/messageRendererV2.js @@ -524,6 +524,15 @@ export class MessageRendererV2 { messageEl.remove(); this.renderedMessages.delete(messageId); } + + /** + * Рендерит одно сообщение (для редактирования) + * @param {Object} message - данные сообщения + * @returns {HTMLElement} - DOM элемент сообщения + */ + renderSingleMessage(message) { + return this._createMessageElement(message); + } // ==================== Создание элементов ====================