feat(communications): добавлена поддержка редактирования сообщений с изменением attachments

Изменения:
- Новый endpoint /api/v1/communications/messages/upload-temp/ для загрузки файлов без создания сообщения
- Поле message в MessageAttachment теперь nullable для временных вложений
- Логика перемещения attachments между сообщениями при редактировании
- Frontend использует upload-temp вместо создания временных сообщений
- Celery задача cleanup_orphaned_attachments для очистки забытых файлов (каждый час)
- Исправлена проблема с WebSocket обновлениями при редактировании
- Удалены debug логи после успешного тестирования

Технические детали:
- communications/models.py: добавлено null=True, blank=True к MessageAttachment.message
- communications/migrations/0022: миграция для изменения поля
- api/v1/communications/viewsets.py: новый action upload_temp() + логика перемещения в update()
- static/js/components/chatComposer.js: использование upload-temp вместо upload
- communications/tasks.py: новая Celery задача для очистки старых orphaned attachments
- eusrr_backend/settings.py: добавлен CELERY_BEAT_SCHEDULE с задачей очистки
This commit is contained in:
Игорь Надеин 2026-01-15 11:13:54 +03:00
parent e14ccbf956
commit 14eac6eecb
16 changed files with 568 additions and 57 deletions

View File

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

View File

@ -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):
"""Пользователь набирает текст"""

View File

@ -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='Ширина'),
),
]

View File

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

View File

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

View File

@ -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 задаче за раз

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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;
}
/**
* Прокручивает к сообщению

View File

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

View File

@ -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);
}
// ==================== Создание элементов ====================