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:
parent
53ec1a7b87
commit
fa0948c159
@ -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'])
|
||||
|
||||
@ -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):
|
||||
"""Пользователь набирает текст"""
|
||||
|
||||
@ -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='Ширина'),
|
||||
),
|
||||
]
|
||||
@ -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/',
|
||||
|
||||
43
backend/communications/tasks.py
Normal file
43
backend/communications/tasks.py
Normal 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
|
||||
@ -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 задаче за раз
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
|
||||
@ -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');
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Прокручивает к сообщению
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
// ==================== Создание элементов ====================
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user