refactor: восстановлены старые стили пузырей сообщений, аватаров и разделителей (pre-redesign)
- Восстановлен мягкий цвет пузырей: color-mix(92%) вместо жесткого var(--bs-primary) - Аватары: border-radius 9999px + grid layout для идеальных кругов - Разделители: правильный spacing, font-weight 700, pointer-events, color-mix прозрачность - Добавлены оптимизации скролла: rootMargin 300px, limit 50 сообщений (Telegram-стиль) - Добавлены новые модули: messageLoader, scrollManager, chatUtils - Рефакторинг логики загрузки сообщений для предотвращения дубликатов - Создана документация по архитектуре и тестированию
This commit is contained in:
parent
1efbe8cd12
commit
6d76988cdd
@ -332,6 +332,122 @@ def load_chat_messages(request, pk: int):
|
||||
)
|
||||
|
||||
|
||||
@login_required
|
||||
@require_GET
|
||||
def load_chat_messages_around(request, pk: int):
|
||||
"""
|
||||
Загрузка сообщений ВОКРУГ конкретного message_id.
|
||||
Современная архитектура как в Telegram/Discord/WhatsApp.
|
||||
|
||||
Параметры:
|
||||
- around_id: ID сообщения-якоря (обычно last_read_message_id)
|
||||
- limit: Общее количество сообщений (по умолчанию 50)
|
||||
|
||||
Возвращает примерно limit/2 сообщений ДО и limit/2 ПОСЛЕ якоря.
|
||||
"""
|
||||
chat = get_object_or_404(Chat, pk=pk)
|
||||
user = request.user
|
||||
|
||||
if not user_can_access_chat(chat, user):
|
||||
return JsonResponse({"ok": False, "error": "forbidden"}, status=403)
|
||||
|
||||
try:
|
||||
limit = int(request.GET.get("limit", 50))
|
||||
except (TypeError, ValueError):
|
||||
limit = 50
|
||||
limit = max(10, min(limit, 100))
|
||||
|
||||
around_id = request.GET.get("around_id")
|
||||
|
||||
# Если нет around_id, пытаемся получить last_read_message_id
|
||||
if not around_id:
|
||||
try:
|
||||
from communications.models import ChatReadState
|
||||
read_state = ChatReadState.objects.filter(
|
||||
chat=chat, user=user
|
||||
).first()
|
||||
|
||||
if read_state and read_state.last_read_message_id:
|
||||
around_id = read_state.last_read_message_id
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Если все еще нет around_id - загружаем последние сообщения
|
||||
if not around_id:
|
||||
return load_chat_messages(request, pk)
|
||||
|
||||
# Находим anchor message
|
||||
# ВАЖНО: around_id может быть либо message_id, либо timestamp
|
||||
# Определяем по размеру: message_id обычно < 1000000, timestamp > 1000000000
|
||||
try:
|
||||
around_value = int(around_id)
|
||||
|
||||
# Если это большое число (> 1 миллиард) - вероятно timestamp в миллисекундах
|
||||
if around_value > 1_000_000_000:
|
||||
# Конвертируем timestamp из миллисекунд в seconds
|
||||
from datetime import datetime, timezone
|
||||
timestamp_seconds = around_value / 1000
|
||||
anchor_dt = datetime.fromtimestamp(timestamp_seconds, tz=timezone.utc)
|
||||
|
||||
# Ищем ближайшее сообщение к этому timestamp
|
||||
anchor = Message.objects.filter(
|
||||
chat=chat,
|
||||
created_at__lte=anchor_dt
|
||||
).order_by('-created_at').first()
|
||||
|
||||
# Если не нашли до этого времени, берем первое после
|
||||
if not anchor:
|
||||
anchor = Message.objects.filter(
|
||||
chat=chat,
|
||||
created_at__gte=anchor_dt
|
||||
).order_by('created_at').first()
|
||||
else:
|
||||
# Это обычный message_id
|
||||
anchor = Message.objects.filter(chat=chat, pk=around_value).first()
|
||||
|
||||
except (ValueError, TypeError):
|
||||
# Невалидный ID - загружаем последние
|
||||
return load_chat_messages(request, pk)
|
||||
|
||||
if not anchor:
|
||||
# Сообщение не найдено - загружаем последние
|
||||
return load_chat_messages(request, pk)
|
||||
|
||||
half = limit // 2
|
||||
|
||||
# Сообщения ДО anchor (более старые)
|
||||
before_qs = (
|
||||
Message.objects.filter(chat=chat, created_at__lt=anchor.created_at)
|
||||
.select_related("author", "reply_to__author")
|
||||
.prefetch_related("attachments", "reactions__user")
|
||||
.order_by("-created_at")[: half]
|
||||
)
|
||||
before_messages = list(reversed(list(before_qs)))
|
||||
|
||||
# Сообщения ПОСЛЕ anchor включая само anchor (более новые)
|
||||
after_qs = (
|
||||
Message.objects.filter(chat=chat, created_at__gte=anchor.created_at)
|
||||
.select_related("author", "reply_to__author")
|
||||
.prefetch_related("attachments", "reactions__user")
|
||||
.order_by("created_at")[: half + 1]
|
||||
)
|
||||
after_messages = list(after_qs)
|
||||
|
||||
# Объединяем
|
||||
all_messages = before_messages + after_messages
|
||||
serialized = [serialize_message(m) for m in all_messages]
|
||||
|
||||
return JsonResponse({
|
||||
"ok": True,
|
||||
"messages": serialized,
|
||||
"anchor_id": anchor.id, # ИСПРАВЛЕНО: возвращаем реальный message_id, а не входной параметр
|
||||
"anchor_index": len(before_messages), # Позиция anchor в массиве
|
||||
"has_more_before": len(before_messages) >= half,
|
||||
"has_more_after": len(after_messages) > half,
|
||||
"total_loaded": len(all_messages)
|
||||
})
|
||||
|
||||
|
||||
# ============ MESSAGE REACTIONS ============
|
||||
|
||||
@login_required
|
||||
|
||||
@ -21,6 +21,7 @@ from .communications.views import (
|
||||
get_message_reactions,
|
||||
get_user_chats,
|
||||
load_chat_messages,
|
||||
load_chat_messages_around,
|
||||
pin_chat,
|
||||
remove_reaction,
|
||||
toggle_chat_notifications,
|
||||
@ -94,6 +95,11 @@ urlpatterns = [
|
||||
load_chat_messages,
|
||||
name="chat_messages",
|
||||
),
|
||||
path(
|
||||
"communications/chats/<int:pk>/messages/around/",
|
||||
load_chat_messages_around,
|
||||
name="chat_messages_around",
|
||||
),
|
||||
path(
|
||||
"communications/chats/<int:chat_id>/pin/",
|
||||
pin_chat,
|
||||
|
||||
@ -333,7 +333,7 @@ class ChatDetailView(LoginRequiredMixin, DetailView, FormView):
|
||||
# отдаём клиенту last_read_at и «первое непрочитанное»
|
||||
read_state = (
|
||||
ChatReadState.objects.filter(chat=chat, user=user)
|
||||
.only("last_read_at")
|
||||
.only("last_read_at", "last_read_message_id")
|
||||
.first()
|
||||
)
|
||||
last_read_at = (
|
||||
@ -341,7 +341,19 @@ class ChatDetailView(LoginRequiredMixin, DetailView, FormView):
|
||||
if read_state and read_state.last_read_at
|
||||
else None
|
||||
)
|
||||
last_read_message_id = (
|
||||
read_state.last_read_message_id
|
||||
if read_state and read_state.last_read_message_id
|
||||
else None
|
||||
)
|
||||
context["last_read_at"] = last_read_at
|
||||
context["last_read_message_id"] = last_read_message_id
|
||||
|
||||
# Передаем last_read_message_id в chat объект для data-атрибута
|
||||
chat.last_read_message_id = last_read_message_id
|
||||
chat.last_read_timestamp = (
|
||||
int(last_read_at.timestamp() * 1000) if last_read_at else 0
|
||||
)
|
||||
|
||||
first_unread = None
|
||||
if last_read_at:
|
||||
|
||||
582
backend/docs/architecture/MESSAGE_LOADING_REFACTORING_PLAN.md
Normal file
582
backend/docs/architecture/MESSAGE_LOADING_REFACTORING_PLAN.md
Normal file
@ -0,0 +1,582 @@
|
||||
# План архитектурного рефакторинга системы загрузки сообщений
|
||||
|
||||
**Дата:** 12.01.2026
|
||||
**Статус:** 🔴 PROPOSAL (требует согласования)
|
||||
**Приоритет:** HIGH - архитектурный долг накопился
|
||||
|
||||
---
|
||||
|
||||
## Текущая проблема
|
||||
|
||||
### Что не так сейчас:
|
||||
|
||||
```
|
||||
❌ ТРИ РАЗНЫХ ПУТИ загрузки:
|
||||
1. handleInitialMessages → renderMessages()
|
||||
2. chatHistoryLoader → forEach + createMessageElement()
|
||||
3. handleNewMessage → renderMessage()
|
||||
|
||||
❌ День-разделители создаются в ТРЕХ местах:
|
||||
- renderMessages() - автоматически в цикле
|
||||
- chatHistoryLoader - вручную
|
||||
- renderMessage() - проверка последнего divider'а
|
||||
|
||||
❌ НЕТ единого состояния сообщений
|
||||
- Сообщения только в DOM
|
||||
- Нет кэша
|
||||
- Сложно управлять обновлениями
|
||||
|
||||
❌ Прямое манипулирование DOM везде
|
||||
- Множественные reflow
|
||||
- Сложно отследить кто что изменил
|
||||
- Нет предсказуемости
|
||||
```
|
||||
|
||||
### Симптомы:
|
||||
|
||||
- 🐛 Scroll jumps - прыжки при загрузке
|
||||
- 🐛 Дубликаты логики по всему коду
|
||||
- 🐛 Day-dividers иногда не появляются
|
||||
- 🐛 Сложно добавлять новые фичи
|
||||
- 🐛 Тяжело дебажить проблемы
|
||||
|
||||
---
|
||||
|
||||
## Современная архитектура (Telegram, WhatsApp, Discord)
|
||||
|
||||
### Ключевые принципы:
|
||||
|
||||
```
|
||||
1. 📦 SINGLE SOURCE OF TRUTH
|
||||
Все сообщения в централизованном Store
|
||||
|
||||
2. 🔄 UNIDIRECTIONAL DATA FLOW
|
||||
State → Render → User Action → State Update → Re-render
|
||||
|
||||
3. 🎯 SEPARATION OF CONCERNS
|
||||
Loader ← → Store ← → Renderer
|
||||
↓
|
||||
ScrollManager
|
||||
|
||||
4. ⚡ OPTIMISTIC UPDATES
|
||||
Показываем сообщение сразу, обновляем после сервера
|
||||
|
||||
5. 🚀 VIRTUAL SCROLLING
|
||||
Рендерим только видимые сообщения (для больших чатов)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Предлагаемая архитектура
|
||||
|
||||
### 1. MessageStore (Централизованное хранилище)
|
||||
|
||||
```javascript
|
||||
class MessageStore {
|
||||
constructor() {
|
||||
this.messages = new Map(); // id → message
|
||||
this.chatMessages = new Map(); // chatId → [messageIds] (sorted)
|
||||
this.listeners = new Set();
|
||||
this.optimisticMessages = new Map(); // tempId → message
|
||||
}
|
||||
|
||||
// API для работы с сообщениями
|
||||
addMessage(message, optimistic = false)
|
||||
addMessages(messages)
|
||||
updateMessage(id, updates)
|
||||
removeMessage(id)
|
||||
getMessage(id)
|
||||
getMessagesForChat(chatId, options = {})
|
||||
|
||||
// Подписка на изменения
|
||||
subscribe(listener)
|
||||
unsubscribe(listener)
|
||||
|
||||
// Утилиты
|
||||
hasMessage(id)
|
||||
getMessagesBetween(chatId, startId, endId)
|
||||
getOldestMessage(chatId)
|
||||
getNewestMessage(chatId)
|
||||
|
||||
// Day dividers вычисляются на лету
|
||||
getMessagesWithDividers(chatId)
|
||||
}
|
||||
```
|
||||
|
||||
**Преимущества:**
|
||||
- ✅ Единственный источник правды
|
||||
- ✅ Легко искать дубликаты
|
||||
- ✅ Кэширование из коробки
|
||||
- ✅ Можно откатывать изменения
|
||||
- ✅ Day-dividers вычисляются централизованно
|
||||
|
||||
---
|
||||
|
||||
### 2. MessageLoader (Единый загрузчик)
|
||||
|
||||
```javascript
|
||||
class MessageLoader {
|
||||
constructor(store, wsConnection) {
|
||||
this.store = store;
|
||||
this.ws = wsConnection;
|
||||
this.loadingState = new Map(); // chatId → {initial, history, status}
|
||||
}
|
||||
|
||||
// ЕДИНСТВЕННЫЙ способ загрузки начальных сообщений
|
||||
async loadInitialMessages(chatId) {
|
||||
if (this.loadingState.get(chatId)?.initial) return;
|
||||
|
||||
const messages = await this.fetchInitialMessages(chatId);
|
||||
this.store.addMessages(messages);
|
||||
return messages;
|
||||
}
|
||||
|
||||
// ЕДИНСТВЕННЫЙ способ загрузки истории
|
||||
async loadHistoryBefore(chatId, beforeId) {
|
||||
const messages = await this.fetchHistoryMessages(chatId, beforeId);
|
||||
this.store.addMessages(messages);
|
||||
return messages;
|
||||
}
|
||||
|
||||
// ЕДИНСТВЕННЫЙ обработчик новых сообщений из WS
|
||||
handleNewMessage(message) {
|
||||
// Проверяем - это подтверждение оптимистичного сообщения?
|
||||
const optimistic = this.store.getOptimisticMessage(message.temp_id);
|
||||
if (optimistic) {
|
||||
this.store.confirmOptimisticMessage(message.temp_id, message);
|
||||
} else {
|
||||
this.store.addMessage(message);
|
||||
}
|
||||
}
|
||||
|
||||
// Оптимистичная отправка
|
||||
sendMessageOptimistically(chatId, content) {
|
||||
const tempId = `temp_${Date.now()}`;
|
||||
const optimisticMessage = {
|
||||
id: tempId,
|
||||
chat_id: chatId,
|
||||
content,
|
||||
author_id: currentUserId,
|
||||
created_ts: Date.now(),
|
||||
status: 'sending'
|
||||
};
|
||||
|
||||
this.store.addMessage(optimisticMessage, true);
|
||||
|
||||
// Отправляем на сервер
|
||||
this.ws.send({ type: 'send_message', ...optimisticMessage });
|
||||
|
||||
return tempId;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Преимущества:**
|
||||
- ✅ Все загрузки через один интерфейс
|
||||
- ✅ Нет дублирования логики
|
||||
- ✅ Легко добавить retry/offline
|
||||
- ✅ Оптимистичные обновления
|
||||
|
||||
---
|
||||
|
||||
### 3. MessageRenderer (Чистый рендеринг)
|
||||
|
||||
```javascript
|
||||
class MessageRenderer {
|
||||
constructor(store, containerId) {
|
||||
this.store = store;
|
||||
this.containerId = containerId;
|
||||
this.renderedMessages = new Set(); // messageIds в DOM
|
||||
}
|
||||
|
||||
// ЕДИНСТВЕННЫЙ метод рендеринга
|
||||
render(chatId, options = {}) {
|
||||
const container = document.getElementById(this.containerId);
|
||||
const messagesWithDividers = this.store.getMessagesWithDividers(chatId);
|
||||
|
||||
// Используем DocumentFragment для батчинга
|
||||
const fragment = document.createDocumentFragment();
|
||||
|
||||
messagesWithDividers.forEach(item => {
|
||||
if (item.type === 'divider') {
|
||||
fragment.appendChild(this.createDayDivider(item.text));
|
||||
} else {
|
||||
fragment.appendChild(this.createMessageElement(item.message));
|
||||
}
|
||||
});
|
||||
|
||||
// ОДНА вставка в DOM
|
||||
container.innerHTML = ''; // или умный diff
|
||||
container.appendChild(fragment);
|
||||
|
||||
this.renderedMessages.clear();
|
||||
messagesWithDividers.forEach(item => {
|
||||
if (item.type === 'message') {
|
||||
this.renderedMessages.add(item.message.id);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Incremental update - добавить одно сообщение
|
||||
appendMessage(message) {
|
||||
if (this.renderedMessages.has(message.id)) return;
|
||||
|
||||
const container = document.getElementById(this.containerId);
|
||||
const messagesWithDividers = this.store.getMessagesWithDividers(message.chat_id);
|
||||
|
||||
// Найти нужно ли добавить divider перед сообщением
|
||||
const needsDivider = this.checkNeedsDayDivider(message, messagesWithDividers);
|
||||
|
||||
if (needsDivider) {
|
||||
container.appendChild(this.createDayDivider(needsDivider.text));
|
||||
}
|
||||
|
||||
container.appendChild(this.createMessageElement(message));
|
||||
this.renderedMessages.add(message.id);
|
||||
}
|
||||
|
||||
// Update существующего сообщения (edit, reaction)
|
||||
updateMessage(messageId, updates) {
|
||||
const element = document.querySelector(`[data-message-id="${messageId}"]`);
|
||||
if (!element) return;
|
||||
|
||||
// Патчим только измененные части
|
||||
if (updates.content) {
|
||||
element.querySelector('.message-content').textContent = updates.content;
|
||||
}
|
||||
if (updates.reactions) {
|
||||
this.updateReactions(element, updates.reactions);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Преимущества:**
|
||||
- ✅ Рендерит только из Store
|
||||
- ✅ Предсказуемый результат
|
||||
- ✅ Легко тестировать
|
||||
- ✅ Day-dividers всегда корректны
|
||||
|
||||
---
|
||||
|
||||
### 4. ScrollManager (Управление прокруткой)
|
||||
|
||||
```javascript
|
||||
class ScrollManager {
|
||||
constructor(scrollElement, messageLoader, messageRenderer) {
|
||||
this.scrollEl = scrollElement;
|
||||
this.loader = messageLoader;
|
||||
this.renderer = messageRenderer;
|
||||
|
||||
this.setupIntersectionObserver();
|
||||
this.setupScrollListener();
|
||||
}
|
||||
|
||||
// Автоматическая загрузка истории при scroll up
|
||||
setupIntersectionObserver() {
|
||||
const firstMessage = this.scrollEl.querySelector('.msg:first-child');
|
||||
|
||||
const observer = new IntersectionObserver(entries => {
|
||||
entries.forEach(entry => {
|
||||
if (entry.isIntersecting) {
|
||||
this.loadMoreHistory();
|
||||
}
|
||||
});
|
||||
}, { threshold: 0.1 });
|
||||
|
||||
if (firstMessage) observer.observe(firstMessage);
|
||||
}
|
||||
|
||||
async loadMoreHistory() {
|
||||
const chatId = this.getChatId();
|
||||
const oldestMessage = this.store.getOldestMessage(chatId);
|
||||
|
||||
if (!oldestMessage) return;
|
||||
|
||||
const prevHeight = this.scrollEl.scrollHeight;
|
||||
|
||||
await this.loader.loadHistoryBefore(chatId, oldestMessage.id);
|
||||
|
||||
// Re-render с новыми сообщениями
|
||||
this.renderer.render(chatId);
|
||||
|
||||
// Восстанавливаем позицию скролла
|
||||
const delta = this.scrollEl.scrollHeight - prevHeight;
|
||||
this.scrollEl.scrollTop += delta;
|
||||
}
|
||||
|
||||
// Умный scroll to bottom БЕЗ прыжков
|
||||
scrollToBottom(options = {}) {
|
||||
const { instant = false, force = false } = options;
|
||||
|
||||
// Не скроллим если пользователь читает историю (если force = false)
|
||||
if (!force && !this.isNearBottom()) return;
|
||||
|
||||
// Скрываем контейнер
|
||||
this.scrollEl.style.visibility = 'hidden';
|
||||
|
||||
// Double RAF для гарантии после layout
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
this.scrollEl.scrollTop = this.scrollEl.scrollHeight;
|
||||
this.scrollEl.style.visibility = '';
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
isNearBottom(threshold = 100) {
|
||||
return (
|
||||
this.scrollEl.scrollTop + this.scrollEl.clientHeight >=
|
||||
this.scrollEl.scrollHeight - threshold
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Преимущества:**
|
||||
- ✅ Вся логика скролла в одном месте
|
||||
- ✅ IntersectionObserver для автозагрузки
|
||||
- ✅ Умное определение "внизу ли пользователь"
|
||||
- ✅ Нет прыжков
|
||||
|
||||
---
|
||||
|
||||
### 5. ChatController (Координатор)
|
||||
|
||||
```javascript
|
||||
class ChatController {
|
||||
constructor(chatId) {
|
||||
this.chatId = chatId;
|
||||
|
||||
// Инициализируем компоненты
|
||||
this.store = new MessageStore();
|
||||
this.loader = new MessageLoader(this.store, userWebSocket);
|
||||
this.renderer = new MessageRenderer(this.store, 'chatScrollContainer');
|
||||
this.scrollManager = new ScrollManager(
|
||||
document.getElementById('chatScrollContainer'),
|
||||
this.loader,
|
||||
this.renderer
|
||||
);
|
||||
|
||||
// Подписываемся на изменения Store
|
||||
this.store.subscribe((event, data) => {
|
||||
this.handleStoreUpdate(event, data);
|
||||
});
|
||||
|
||||
// Подписываемся на WebSocket
|
||||
this.subscribeToWebSocket();
|
||||
}
|
||||
|
||||
async init() {
|
||||
// Загружаем начальные сообщения
|
||||
await this.loader.loadInitialMessages(this.chatId);
|
||||
|
||||
// Рендерим
|
||||
this.renderer.render(this.chatId);
|
||||
|
||||
// Скроллим вниз
|
||||
this.scrollManager.scrollToBottom({ instant: true, force: true });
|
||||
}
|
||||
|
||||
handleStoreUpdate(event, data) {
|
||||
switch (event) {
|
||||
case 'message_added':
|
||||
// Новое сообщение - incremental render
|
||||
this.renderer.appendMessage(data.message);
|
||||
|
||||
// Автоскролл если внизу или наше сообщение
|
||||
if (data.message.author_id === currentUserId || this.scrollManager.isNearBottom()) {
|
||||
this.scrollManager.scrollToBottom();
|
||||
}
|
||||
break;
|
||||
|
||||
case 'message_updated':
|
||||
// Обновление - патчим DOM
|
||||
this.renderer.updateMessage(data.messageId, data.updates);
|
||||
break;
|
||||
|
||||
case 'message_removed':
|
||||
// Удаление
|
||||
this.renderer.removeMessage(data.messageId);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
subscribeToWebSocket() {
|
||||
userWebSocket.on('new_message', (data) => {
|
||||
if (data.message.chat_id === this.chatId) {
|
||||
this.loader.handleNewMessage(data.message);
|
||||
}
|
||||
});
|
||||
|
||||
userWebSocket.on('message_edited', (data) => {
|
||||
this.store.updateMessage(data.message.id, data.message);
|
||||
});
|
||||
|
||||
// ... другие события
|
||||
}
|
||||
|
||||
sendMessage(content) {
|
||||
return this.loader.sendMessageOptimistically(this.chatId, content);
|
||||
}
|
||||
}
|
||||
|
||||
// Usage:
|
||||
const chat = new ChatController(chatId);
|
||||
await chat.init();
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Архитектурная диаграмма
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ ChatController │
|
||||
│ (координирует все компоненты) │
|
||||
└────────┬────────────────────────────────┬───────┘
|
||||
│ │
|
||||
▼ ▼
|
||||
┌─────────────────┐ ┌─────────────────┐
|
||||
│ MessageLoader │◄────────────►│ MessageStore │
|
||||
│ │ │ (Single Source │
|
||||
│ - loadInitial() │ │ of Truth) │
|
||||
│ - loadHistory() │ │ │
|
||||
│ - handleNew() │ │ Map<id, msg> │
|
||||
└────────┬────────┘ └────────┬────────┘
|
||||
│ │
|
||||
│ │
|
||||
▼ ▼
|
||||
┌─────────────────┐ ┌─────────────────┐
|
||||
│ WebSocket │ │ MessageRenderer │
|
||||
│ │ │ │
|
||||
│ - new_message │ │ - render() │
|
||||
│ - message_edit │ │ - append() │
|
||||
│ - reaction │ │ - update() │
|
||||
└─────────────────┘ └────────┬────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────┐
|
||||
│ ScrollManager │
|
||||
│ │
|
||||
│ - scrollToBottom│
|
||||
│ - loadMore() │
|
||||
│ - isNearBottom │
|
||||
└─────────────────┘
|
||||
|
||||
ПОТОК ДАННЫХ (однонаправленный):
|
||||
WebSocket → Loader → Store → Renderer → DOM
|
||||
↑ ↓
|
||||
Controller ← ScrollManager
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## План миграции
|
||||
|
||||
### Фаза 1: Подготовка (1-2 дня)
|
||||
- [ ] Создать MessageStore класс
|
||||
- [ ] Написать тесты для Store
|
||||
- [ ] Создать MessageLoader класс (пока без оптимистики)
|
||||
- [ ] Протестировать совместимость с текущим API
|
||||
|
||||
### Фаза 2: Рефакторинг Renderer (1 день)
|
||||
- [ ] Переписать MessageRenderer для работы со Store
|
||||
- [ ] Убрать прямые манипуляции DOM
|
||||
- [ ] Централизовать логику day-dividers в Store
|
||||
|
||||
### Фаза 3: Интеграция (2 дня)
|
||||
- [ ] Создать ChatController
|
||||
- [ ] Переписать chatDetail.js для использования Controller
|
||||
- [ ] Удалить старый chatHistoryLoader
|
||||
- [ ] Удалить дублирующую логику из userWebSocket
|
||||
|
||||
### Фаза 4: ScrollManager (1 день)
|
||||
- [ ] Создать ScrollManager класс
|
||||
- [ ] Переместить всю логику scroll из разных мест
|
||||
- [ ] Реализовать IntersectionObserver для автозагрузки
|
||||
|
||||
### Фаза 5: Оптимизации (1-2 дня)
|
||||
- [ ] Реализовать оптимистичные обновления
|
||||
- [ ] Добавить виртуальный скроллинг (опционально)
|
||||
- [ ] Оптимизировать re-render (virtual DOM или incremental DOM)
|
||||
|
||||
### Фаза 6: Cleanup (1 день)
|
||||
- [ ] Удалить весь старый код
|
||||
- [ ] Обновить документацию
|
||||
- [ ] Написать migration guide
|
||||
|
||||
**Общее время: 7-10 дней разработки**
|
||||
|
||||
---
|
||||
|
||||
## Преимущества новой архитектуры
|
||||
|
||||
### Для разработки:
|
||||
✅ **Предсказуемость** - всегда знаем где находится state
|
||||
✅ **Тестируемость** - каждый компонент изолирован
|
||||
✅ **Расширяемость** - легко добавлять новые фичи
|
||||
✅ **Читаемость** - четкое разделение ответственности
|
||||
✅ **Дебаг** - легко отследить откуда пришло изменение
|
||||
|
||||
### Для пользователя:
|
||||
✅ **Нет прыжков** - scroll управляется централизованно
|
||||
✅ **Быстрее** - меньше reflow, умный batching
|
||||
✅ **Надежнее** - нет race conditions и дубликатов
|
||||
✅ **Плавнее** - оптимистичные обновления
|
||||
✅ **Офлайн-режим** - Store позволяет кэшировать
|
||||
|
||||
---
|
||||
|
||||
## Риски и митигация
|
||||
|
||||
### ⚠️ Риск: Большой объем изменений
|
||||
**Митигация:** Поэтапная миграция, можно держать старый код параллельно
|
||||
|
||||
### ⚠️ Риск: Регрессии в существующих фичах
|
||||
**Митигация:** Писать тесты для каждого компонента, мануальное тестирование
|
||||
|
||||
### ⚠️ Риск: Увеличение размера бандла
|
||||
**Митигация:** Классы легко минифицируются, можно сделать code splitting
|
||||
|
||||
### ⚠️ Риск: Breaking changes для других частей кода
|
||||
**Митигация:** Сохранить обратную совместимость API где возможно
|
||||
|
||||
---
|
||||
|
||||
## Альтернатива: Минимальный рефакторинг
|
||||
|
||||
Если полный рефакторинг слишком рискованный, можно сделать **минимальный**:
|
||||
|
||||
### Вариант "Быстрый фикс":
|
||||
1. ✅ Создать один метод `addMessageToChat(message, position)`
|
||||
2. ✅ Все три пути (initial, history, new) используют его
|
||||
3. ✅ Day-dividers проверяются ВСЕГДА внутри этого метода
|
||||
4. ✅ Scroll логика в одной функции
|
||||
|
||||
**Время: 1-2 дня**
|
||||
**Плюсы:** Быстро, низкий риск
|
||||
**Минусы:** Не решает архитектурные проблемы полностью
|
||||
|
||||
---
|
||||
|
||||
## Решение?
|
||||
|
||||
**Предлагаю два варианта:**
|
||||
|
||||
### 🚀 Вариант A: Полный рефакторинг (рекомендуется)
|
||||
- Современная архитектура
|
||||
- Решает ВСЕ проблемы
|
||||
- Легко поддерживать в будущем
|
||||
- **Время: 7-10 дней**
|
||||
|
||||
### ⚡ Вариант B: Быстрый фикс
|
||||
- Минимальные изменения
|
||||
- Решает текущие баги
|
||||
- Архитектурный долг остается
|
||||
- **Время: 1-2 дня**
|
||||
|
||||
**Что выбираешь?**
|
||||
|
||||
Если согласен на Вариант A - начну с создания MessageStore и тестов.
|
||||
Если нужен Вариант B - сделаю единый метод `addMessageToChat()` прямо сейчас.
|
||||
297
backend/docs/guides/WEBSOCKET_INTEGRATION.md
Normal file
297
backend/docs/guides/WEBSOCKET_INTEGRATION.md
Normal file
@ -0,0 +1,297 @@
|
||||
# WebSocket Integration с ChatController
|
||||
|
||||
**Дата:** 12.01.2026
|
||||
**Статус:** ✅ Интеграция завершена
|
||||
|
||||
---
|
||||
|
||||
## Архитектура событий
|
||||
|
||||
### Новая система событий
|
||||
|
||||
UserWebSocket теперь **dispatch'ит глобальные события** вместо прямого вызова компонентов:
|
||||
|
||||
```
|
||||
WebSocket Server
|
||||
↓
|
||||
userWebSocket.js (получает JSON)
|
||||
↓
|
||||
window.dispatchEvent('ws:*') ← НОВОЕ
|
||||
↓
|
||||
ChatController (слушает события)
|
||||
↓
|
||||
MessageLoader → MessageStore
|
||||
↓
|
||||
Store событие → MessageRenderer
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## События WebSocket
|
||||
|
||||
### 1. `ws:initial-messages`
|
||||
|
||||
**Когда:** После открытия чата и получения истории
|
||||
|
||||
**Данные:**
|
||||
```javascript
|
||||
{
|
||||
detail: {
|
||||
messages: Array, // Массив сообщений
|
||||
chatId: number // ID чата
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Обработка:**
|
||||
```javascript
|
||||
window.addEventListener('ws:initial-messages', (event) => {
|
||||
const { messages, chatId } = event.detail;
|
||||
// ChatController автоматически рендерит и скроллит
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2. `ws:new-message`
|
||||
|
||||
**Когда:** Получено новое сообщение в реальном времени
|
||||
|
||||
**Данные:**
|
||||
```javascript
|
||||
{
|
||||
detail: {
|
||||
message: Object, // Полный объект сообщения
|
||||
chatId: number, // ID чата
|
||||
isOwnMessage: boolean // true если отправитель - мы
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Обработка:**
|
||||
```javascript
|
||||
window.addEventListener('ws:new-message', (event) => {
|
||||
const { message, isOwnMessage } = event.detail;
|
||||
// MessageLoader добавит в Store
|
||||
// Renderer сделает incremental update
|
||||
// Autoscroll если isOwnMessage или nearBottom
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3. `ws:message-edited`
|
||||
|
||||
**Когда:** Сообщение отредактировано
|
||||
|
||||
**Данные:**
|
||||
```javascript
|
||||
{
|
||||
detail: {
|
||||
message: Object, // Обновленное сообщение
|
||||
chatId: number
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Обработка:**
|
||||
```javascript
|
||||
window.addEventListener('ws:message-edited', (event) => {
|
||||
const { message } = event.detail;
|
||||
// Store обновит сообщение
|
||||
// Renderer сделает DOM patching
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 4. `ws:message-removed`
|
||||
|
||||
**Когда:** Сообщение удалено
|
||||
|
||||
**Данные:**
|
||||
```javascript
|
||||
{
|
||||
detail: {
|
||||
messageId: number, // ID удаленного сообщения
|
||||
chatId: number
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Обработка:**
|
||||
```javascript
|
||||
window.addEventListener('ws:message-removed', (event) => {
|
||||
const { messageId } = event.detail;
|
||||
// Store удалит сообщение
|
||||
// Renderer удалит из DOM с анимацией
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 5. `ws:reaction-added`
|
||||
|
||||
**Когда:** Добавлена реакция к сообщению
|
||||
|
||||
**Данные:**
|
||||
```javascript
|
||||
{
|
||||
detail: {
|
||||
messageId: number,
|
||||
emoji: string,
|
||||
userId: number,
|
||||
userName: string,
|
||||
reactionsSummary: Object, // Полная сводка реакций
|
||||
chatId: number
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 6. `ws:reaction-removed`
|
||||
|
||||
**Когда:** Удалена реакция
|
||||
|
||||
**Данные:**
|
||||
```javascript
|
||||
{
|
||||
detail: {
|
||||
messageId: number,
|
||||
emoji: string,
|
||||
userId: number,
|
||||
reactionsSummary: Object,
|
||||
chatId: number
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Backward Compatibility
|
||||
|
||||
### Старые события сохранены
|
||||
|
||||
Для совместимости со старым кодом, userWebSocket **продолжает dispatch'ить старые события**:
|
||||
|
||||
- `chat:initial-messages-loaded`
|
||||
- `chat:message-added`
|
||||
- `chat:message-edited`
|
||||
- `chat:reaction-added`
|
||||
- `chat:reaction-removed`
|
||||
|
||||
### Старый messageRenderer работает
|
||||
|
||||
Если `options.messageRenderer` передан в `initUserWebSocket()`, старая логика рендеринга **продолжает работать** параллельно с новой:
|
||||
|
||||
```javascript
|
||||
// В userWebSocket.js
|
||||
if (options.messageRenderer && messages?.length) {
|
||||
console.log('[UserWS] [DEPRECATED] Using old messageRenderer');
|
||||
options.messageRenderer.renderMessages(messages);
|
||||
}
|
||||
```
|
||||
|
||||
**Это временная мера** - удалится после полной миграции на ChatController.
|
||||
|
||||
---
|
||||
|
||||
## Миграция на ChatController
|
||||
|
||||
### До (старый способ):
|
||||
|
||||
```javascript
|
||||
// chatDetail.js - СТАРОЕ
|
||||
const messageRenderer = new MessageRenderer({...});
|
||||
userWs.configure({ messageRenderer });
|
||||
|
||||
// Прямой вызов методов
|
||||
messageRenderer.renderMessages(messages);
|
||||
messageRenderer.renderMessage(message);
|
||||
```
|
||||
|
||||
### После (новый способ):
|
||||
|
||||
```javascript
|
||||
// chatDetail.js - НОВОЕ
|
||||
import { ChatController } from '../controllers/chatController.js';
|
||||
|
||||
const chatController = new ChatController({
|
||||
chatId: currentChatId,
|
||||
currentUserId: currentUserId,
|
||||
scrollElement: document.getElementById('chatScroll'),
|
||||
wsConnection: window.userWebSocket
|
||||
});
|
||||
|
||||
await chatController.init();
|
||||
|
||||
// Всё автоматически!
|
||||
// - WebSocket dispatch'ит события
|
||||
// - ChatController слушает
|
||||
// - MessageLoader обновляет Store
|
||||
// - Store dispatch'ит события
|
||||
// - Renderer обновляет DOM
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Debugging
|
||||
|
||||
### Проверка событий
|
||||
|
||||
В консоли браузера:
|
||||
|
||||
```javascript
|
||||
// Слушаем все WS события
|
||||
['ws:initial-messages', 'ws:new-message', 'ws:message-edited',
|
||||
'ws:message-removed', 'ws:reaction-added', 'ws:reaction-removed']
|
||||
.forEach(eventName => {
|
||||
window.addEventListener(eventName, (e) => {
|
||||
console.log(`🔔 ${eventName}:`, e.detail);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### Проверка ChatController
|
||||
|
||||
```javascript
|
||||
// Получить статус контроллера
|
||||
window.chatController?.getStatus();
|
||||
// Вернет:
|
||||
// {
|
||||
// initialized: true,
|
||||
// chatId: 123,
|
||||
// messageCount: 45,
|
||||
// loadingState: {...},
|
||||
// scrollPosition: {...}
|
||||
// }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Следующие шаги
|
||||
|
||||
1. ✅ userWebSocket интегрирован (DONE)
|
||||
2. ⏳ Обновить chatDetail.js для использования ChatController
|
||||
3. ⏳ Удалить старый messageRenderer после миграции
|
||||
4. ⏳ Удалить старый chatHistoryLoader
|
||||
5. ⏳ Удалить backward compatibility код из userWebSocket
|
||||
|
||||
---
|
||||
|
||||
## Преимущества новой архитектуры
|
||||
|
||||
### До:
|
||||
- ❌ userWebSocket напрямую вызывал messageRenderer
|
||||
- ❌ Tight coupling между модулями
|
||||
- ❌ Сложно тестировать
|
||||
- ❌ Нельзя заменить компоненты
|
||||
|
||||
### После:
|
||||
- ✅ Событийная архитектура (Event-Driven)
|
||||
- ✅ Loose coupling - модули независимы
|
||||
- ✅ Легко тестировать (mock события)
|
||||
- ✅ Можно заменить любой компонент
|
||||
- ✅ Единая точка входа (ChatController)
|
||||
- ✅ Single Source of Truth (MessageStore)
|
||||
367
backend/docs/reports/DUPLICATE_LOGIC_AUDIT.md
Normal file
367
backend/docs/reports/DUPLICATE_LOGIC_AUDIT.md
Normal file
@ -0,0 +1,367 @@
|
||||
# Аудит дубликатов логики во frontend
|
||||
|
||||
**Дата:** 2024
|
||||
**Контекст:** Пользователь сообщил о проблемах, несмотря на 6 попыток исправлений:
|
||||
1. Прыжки при прокрутке остались
|
||||
2. Старые разделители дат (разные стили)
|
||||
3. Дублирование логов в консоли (MessageContextMenu)
|
||||
4. Страница не загружается с последнего сообщения
|
||||
|
||||
## Критические находки
|
||||
|
||||
### 1. ❌ MessageContextMenu - ДВОЙНАЯ ОБРАБОТКА
|
||||
|
||||
**Проблема:**
|
||||
```javascript
|
||||
// messageContextMenu.js - ДВА способа обработки
|
||||
1. MutationObserver (строки 85-128) - следит за добавлением в DOM
|
||||
2. Event listener на 'chat:message-added' (в chat-detail-enhanced.js)
|
||||
```
|
||||
|
||||
**Результат:** Каждое сообщение обрабатывается ДВАЖДЫ:
|
||||
- Сначала через MutationObserver когда добавляется в DOM
|
||||
- Потом через событие `chat:message-added` из userWebSocket.js:333
|
||||
|
||||
**Доказательство из console.log:**
|
||||
```
|
||||
[MessageContextMenu] New message detected: 434
|
||||
[MessageContextMenu] attachToMessage called: {messageId: '434'...}
|
||||
[MessageContextMenu] All event listeners attached to message: 434
|
||||
[MessageContextMenu] New message detected: 435 ← ДУБЛЬ!
|
||||
[MessageContextMenu] attachToMessage called: {messageId: '435'...} ← ДУБЛЬ!
|
||||
```
|
||||
|
||||
**Решение:** ✅ ИСПРАВЛЕНО
|
||||
- Удален MutationObserver из messageContextMenu.js
|
||||
- Оставлена только событийная модель через `chat:message-added`
|
||||
- Один путь обработки = нет дублей
|
||||
|
||||
---
|
||||
|
||||
### 2. ❌ Day Dividers - ДВА НЕСОВМЕСТИМЫХ МЕТОДА
|
||||
|
||||
**Проблема:**
|
||||
```javascript
|
||||
// СТАРЫЙ способ (chatMessageTemplates.js:102)
|
||||
function createDayDivider(dateStr) {
|
||||
div.className = 'day-divider'; // Только один класс!
|
||||
return div;
|
||||
}
|
||||
|
||||
// НОВЫЙ способ (messageRenderer.js:72+)
|
||||
const dividerEl = document.createElement('div');
|
||||
dividerEl.className = 'day-divider text-center small text-muted my-3'; // Полный стиль!
|
||||
dividerEl.innerHTML = `<span class="px-3 py-1 rounded-pill bg-light">${msgDay}</span>`;
|
||||
```
|
||||
|
||||
**Где использовались:**
|
||||
- ✅ `userWebSocket.js` → импортировал но НЕ использовал (мертвый код)
|
||||
- ❌ `chatHistoryLoader.js:136` → ИСПОЛЬЗОВАЛ старый метод!
|
||||
- ✅ `messageRenderer.js` → использует новый метод
|
||||
|
||||
**Результат:** Пользователь видит РАЗНЫЕ стили разделителей дат:
|
||||
- История (scroll up) → минималистичный стиль
|
||||
- Новые сообщения → полный стиль с фоном
|
||||
|
||||
**Решение:** ✅ ИСПРАВЛЕНО
|
||||
- Удален импорт `createDayDivider` из userWebSocket.js
|
||||
- Удален импорт и использование `createDayDivider` из chatHistoryLoader.js
|
||||
- Теперь messageRenderer автоматически создает dividers при renderMessages()
|
||||
- Единый стиль везде
|
||||
|
||||
---
|
||||
### 3. ❌ Day Dividers - ТРИ РАЗНЫХ ПУТИ ОБРАБОТКИ
|
||||
|
||||
**Архитектурная проблема:**
|
||||
```javascript
|
||||
// БЫЛО: Три несовместимых способа загрузки сообщений
|
||||
|
||||
1️⃣ НАЧАЛЬНЫЕ сообщения (userWebSocket.handleInitialMessages)
|
||||
→ messageRenderer.renderMessages(messages)
|
||||
→ ✅ Создает day-dividers в цикле
|
||||
|
||||
2️⃣ ИСТОРИЯ при scroll up (chatHistoryLoader)
|
||||
→ createMessageElement() для каждого сообщения
|
||||
→ ❌ НЕ создавал day-dividers (старый createDayDivider удален)
|
||||
→ Результат: Пользователь видел даты только в начале
|
||||
|
||||
3️⃣ НОВЫЕ сообщения в реальном времени (userWebSocket.handleNewMessage)
|
||||
→ messageRenderer.renderMessage(message)
|
||||
→ ❌ НЕ проверял нужен ли day-divider
|
||||
→ Результат: Новые сообщения на следующий день БЕЗ даты!
|
||||
```
|
||||
|
||||
**Почему так получилось:**
|
||||
- После рефакторинга убрали старый `createDayDivider` из chatMessageTemplates
|
||||
- `renderMessages()` (batch) создает dividers автоматически
|
||||
- `renderMessage()` (single) НЕ создавал dividers - это oversight!
|
||||
- chatHistoryLoader использовал `createMessageElement()` напрямую без dividers
|
||||
|
||||
**Решение:** ✅ ИСПРАВЛЕНО
|
||||
|
||||
1. **chatHistoryLoader.js:**
|
||||
```javascript
|
||||
// Теперь создает day-dividers ВРУЧНУЮ с правильным стилем
|
||||
messages.forEach((msg) => {
|
||||
const msgDate = new Date(toTimestamp(msg));
|
||||
const day = messageRenderer.formatDay(msgDate);
|
||||
|
||||
if (day !== prevDay) {
|
||||
// Создаем divider с единым стилем
|
||||
const dividerEl = document.createElement('div');
|
||||
dividerEl.className = 'day-divider text-center small text-muted my-3';
|
||||
dividerEl.innerHTML = `<span class="px-3 py-1 rounded-pill bg-light">${day}</span>`;
|
||||
fragment.appendChild(dividerEl);
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
2. **messageRenderer.renderMessage():**
|
||||
```javascript
|
||||
// Теперь ПРОВЕРЯЕТ нужен ли day-divider перед новым сообщением
|
||||
const msgDate = new Date(msg.created_ts);
|
||||
const msgDay = this.formatDay(msgDate);
|
||||
|
||||
// Находим последний day-divider
|
||||
const lastDivider = container.querySelector('.day-divider:last-of-type');
|
||||
const lastDividerText = lastDivider ? lastDivider.textContent.trim() : null;
|
||||
|
||||
// Если день изменился - создаем divider
|
||||
if (!lastDividerText || lastDividerText !== msgDay) {
|
||||
const dividerEl = document.createElement('div');
|
||||
dividerEl.className = 'day-divider text-center small text-muted my-3';
|
||||
dividerEl.innerHTML = `<span class="px-3 py-1 rounded-pill bg-light">${msgDay}</span>`;
|
||||
container.appendChild(dividerEl);
|
||||
}
|
||||
```
|
||||
|
||||
**Теперь ВСЕ три пути создают day-dividers единообразно!**
|
||||
|
||||
---
|
||||
### 3. 🟡 Неиспользуемые импорты
|
||||
|
||||
**Найдено:**
|
||||
```javascript
|
||||
// userWebSocket.js строка 13 - БЫЛО
|
||||
import { formatDay, createDayDivider, createMessageElement } from './chatMessageTemplates.js';
|
||||
// Использовался только formatDay!
|
||||
```
|
||||
|
||||
**Решение:** ✅ ИСПРАВЛЕНО
|
||||
```javascript
|
||||
// СТАЛО - только то что нужно
|
||||
import { getChatAvatar } from './chatAvatarMap.js';
|
||||
// formatDay тоже не нужен - messageRenderer сам форматирует
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Архитектурные выводы
|
||||
|
||||
### До исправлений
|
||||
|
||||
```
|
||||
MESSAGE RENDERING:
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ userWebSocket.js │
|
||||
│ ├─ Импортирует но не использует createDayDivider
|
||||
│ └─ Использует messageRenderer.renderMessages()│
|
||||
└─────────────────────────────────────────────────┘
|
||||
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ chatHistoryLoader.js │
|
||||
│ ├─ Импортирует createDayDivider │
|
||||
│ ├─ СОЗДАЕТ day-dividers ВРУЧНУЮ (старый стиль) │
|
||||
│ └─ Не использует messageRenderer полностью │
|
||||
└─────────────────────────────────────────────────┘
|
||||
|
||||
РЕЗУЛЬТАТ: РАЗНЫЕ СТИЛИ day-dividers!
|
||||
```
|
||||
|
||||
```
|
||||
MESSAGE CONTEXT MENU:
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ messageContextMenu.js │
|
||||
│ ├─ MutationObserver ────────────┐ │
|
||||
│ │ (следит за DOM) │ │
|
||||
│ │ ▼ │
|
||||
│ └─ Event listener ─────────> attachToMessage() │
|
||||
│ (chat:message-added) ▲ │
|
||||
│ │ │
|
||||
│ userWebSocket.js ───────────────┘ │
|
||||
│ dispatches event │
|
||||
└─────────────────────────────────────────────────┘
|
||||
|
||||
РЕЗУЛЬТАТ: ДВОЙНАЯ ОБРАБОТКА каждого сообщения!
|
||||
```
|
||||
|
||||
### После исправлений
|
||||
|
||||
```
|
||||
MESSAGE RENDERING (ЕДИНЫЙ ПУТЬ):
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ messageRenderer.js - ЕДИНСТВЕННЫЙ ИСТОЧНИК │
|
||||
│ ├─ renderMessages() для начальной загрузки │
|
||||
│ ├─ createMessageElement() для новых сообщений │
|
||||
│ └─ Автоматически создает day-dividers │
|
||||
└─────────────────────────────────────────────────┘
|
||||
▲ ▲
|
||||
│ │
|
||||
┌──────┴─────────┐ ┌──────────┴────────┐
|
||||
│ userWebSocket │ │ chatHistoryLoader │
|
||||
│ (initial load) │ │ (pagination) │
|
||||
└────────────────┘ └───────────────────┘
|
||||
|
||||
РЕЗУЛЬТАТ: ЕДИНЫЙ СТИЛЬ везде!
|
||||
```
|
||||
|
||||
```
|
||||
MESSAGE CONTEXT MENU (ЕДИНЫЙ ПУТЬ):
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ userWebSocket.js │
|
||||
│ └─ Отправляет событие 'chat:message-added' │
|
||||
└─────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ chat-detail-enhanced.js │
|
||||
│ └─ Слушает 'chat:message-added' │
|
||||
│ └─> MessageContextMenu.attachToMessage() │
|
||||
└─────────────────────────────────────────────────┘
|
||||
|
||||
РЕЗУЛЬТАТ: Одна обработка на сообщение!
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Что еще проверить
|
||||
|
||||
### ⚠️ Прыжки при прокрутке (ОСТАЛИСЬ)
|
||||
|
||||
**Гипотезы:**
|
||||
1. **Асинхронность рендеринга:**
|
||||
- MessageRenderer использует DocumentFragment
|
||||
- Но browser paint может быть ПОСЛЕ scrollTop = scrollHeight
|
||||
|
||||
2. **CSS transitions:**
|
||||
```css
|
||||
/* Проверить есть ли transitions которые вызывают reflow */
|
||||
.msg { transition: all 0.3s; } /* ← Может вызывать прыжки */
|
||||
```
|
||||
|
||||
3. **Последовательность операций:**
|
||||
```javascript
|
||||
// ТЕКУЩИЙ КОД (может быть неправильный)
|
||||
container.appendChild(fragment); // 1. Добавляем сообщения
|
||||
scrollEl.scrollTop = scrollEl.scrollHeight; // 2. Скроллим
|
||||
|
||||
// ПРОБЛЕМА: browser может отрисовать container ПОСЛЕ установки scroll
|
||||
|
||||
// ВОЗМОЖНОЕ РЕШЕНИЕ:
|
||||
container.appendChild(fragment);
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => { // ДВОЙНОЙ RAF!
|
||||
scrollEl.scrollTop = scrollEl.scrollHeight;
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
4. **Проверить base.html скрипты:**
|
||||
- Возможно что-то в base.html делает scroll
|
||||
- Или инициализирует что-то что конфликтует
|
||||
|
||||
**Следующие шаги:**
|
||||
1. Добавить логирование в handleInitialMessages:
|
||||
```javascript
|
||||
console.log('[SCROLL] 1. Before render', scrollEl.scrollHeight);
|
||||
messageRenderer.renderMessages(...);
|
||||
console.log('[SCROLL] 2. After render', scrollEl.scrollHeight);
|
||||
requestAnimationFrame(() => {
|
||||
console.log('[SCROLL] 3. RAF 1', scrollEl.scrollHeight);
|
||||
scrollEl.scrollTop = scrollEl.scrollHeight;
|
||||
requestAnimationFrame(() => {
|
||||
console.log('[SCROLL] 4. RAF 2', scrollEl.scrollHeight, scrollEl.scrollTop);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
2. Проверить DevTools Performance профиль:
|
||||
- Записать загрузку страницы
|
||||
- Найти когда происходит scroll
|
||||
- Посмотреть что вызывает reflow
|
||||
|
||||
3. Временно отключить ВСЕ animations:
|
||||
```css
|
||||
* { transition: none !important; animation: none !important; }
|
||||
```
|
||||
Если прыжки исчезнут = проблема в CSS
|
||||
|
||||
---
|
||||
|
||||
## Резюме изменений
|
||||
|
||||
### Файлы изменены:
|
||||
|
||||
1. **userWebSocket.js**
|
||||
- ❌ Удален импорт `createDayDivider`, `createMessageElement`
|
||||
- ✅ Оставлен только `getChatAvatar`
|
||||
- ✅ Добавлено подробное логирование scroll для диагностики (7 контрольных точек)
|
||||
- ✅ Реализован двойной requestAnimationFrame для гарантии после layout
|
||||
|
||||
2. **messageContextMenu.js**
|
||||
- ❌ Удален MutationObserver (строки 85-128)
|
||||
- ✅ Добавлен комментарий о событийной модели
|
||||
- ✅ Теперь ТОЛЬКО через `chat:message-added`
|
||||
|
||||
3. **chatHistoryLoader.js**
|
||||
- ❌ Удален импорт `createDayDivider`
|
||||
- ✅ Добавлена логика создания day-dividers ВРУЧНУЮ с правильным стилем
|
||||
- ✅ Использует `messageRenderer.formatDay()` для единообразия
|
||||
|
||||
4. **messageRenderer.js** (НОВОЕ!)
|
||||
- ✅ Метод `renderMessage()` теперь ПРОВЕРЯЕТ нужен ли day-divider
|
||||
- ✅ Сравнивает с последним divider'ом и создает новый если день изменился
|
||||
- ✅ Единый стиль dividers во всех трех путях загрузки
|
||||
|
||||
### Метрики:
|
||||
|
||||
- **Удалено строк кода:** ~50
|
||||
- **Добавлено строк кода:** ~60 (логирование + day-divider логика)
|
||||
- **Удалено дублирующих систем:** 2 (MutationObserver, старый createDayDivider)
|
||||
- **Неиспользуемых импортов удалено:** 3
|
||||
- **Исправлено критических багов:** 3
|
||||
1. Двойная обработка MessageContextMenu
|
||||
2. Отсутствие day-dividers в истории
|
||||
3. Отсутствие day-dividers в новых сообщениях
|
||||
|
||||
### Тестирование:
|
||||
|
||||
Проверить:
|
||||
- [ ] Разделители дат имеют ЕДИНЫЙ стиль (с фоном, центрированные)
|
||||
- [ ] В консоли НЕТ дублирующих логов MessageContextMenu
|
||||
- [ ] История (scroll up) показывает правильные dividers
|
||||
- [ ] Новые сообщения показывают правильные dividers
|
||||
- [ ] Контекстное меню работает на всех сообщениях
|
||||
- [ ] Реакции работают
|
||||
- [ ] ⚠️ Прыжки при прокрутке - требует дополнительного исследования
|
||||
|
||||
---
|
||||
|
||||
## Дальнейшие действия
|
||||
|
||||
### Приоритет 1: Scroll jumps
|
||||
- Добавить подробное логирование
|
||||
- Проверить timing browser paint
|
||||
- Попробовать двойной requestAnimationFrame
|
||||
- Временно отключить CSS transitions
|
||||
|
||||
### Приоритет 2: Проверка base.html
|
||||
- Убедиться что userWebSocket инициализируется ОДИН раз
|
||||
- Проверить что `user:ws-ready` dispatch происходит один раз
|
||||
- Убедиться что нет конфликтов со скриптами base template
|
||||
|
||||
### Приоритет 3: Cleanup
|
||||
- Можно удалить `createDayDivider` из chatMessageTemplates.js (не используется)
|
||||
- Добавить JSDoc комментарии
|
||||
- Обновить REFACTORING_SUMMARY.md
|
||||
|
||||
245
backend/docs/reports/MESSENGER_ARCHITECTURE_ANALYSIS.md
Normal file
245
backend/docs/reports/MESSENGER_ARCHITECTURE_ANALYSIS.md
Normal file
@ -0,0 +1,245 @@
|
||||
# Анализ архитектуры мессенджера: Проблемы и решения
|
||||
|
||||
**Дата:** 12 января 2026
|
||||
**Статус:** Критические проблемы найдены
|
||||
|
||||
## 🔴 Ключевые проблемы
|
||||
|
||||
### 1. **Отсутствует загрузка "вокруг" сообщения**
|
||||
|
||||
**Текущая реализация:**
|
||||
```python
|
||||
# GET /api/v1/communications/chats/{id}/messages/?before_id=X&limit=50
|
||||
# Загружает только СТАРЫЕ сообщения (before)
|
||||
```
|
||||
|
||||
**Проблема:**
|
||||
- При открытии чата с 1000 сообщений (950 прочитано, 50 новых)
|
||||
- Загружаются последние 50 сообщений
|
||||
- Но скролл должен быть на сообщении #950 (last read)
|
||||
- Пользователь не видит контекст ВОКРУГ last read
|
||||
|
||||
**Как в Telegram/WhatsApp:**
|
||||
```
|
||||
GET /messages?around=950&limit=50
|
||||
→ Возвращает: сообщения 925-975 (25 до + 25 после)
|
||||
→ Скролл на сообщении #950
|
||||
```
|
||||
|
||||
### 2. **LastRead хранится как timestamp вместо message_id**
|
||||
|
||||
**Текущая реализация:**
|
||||
```python
|
||||
# ChatReadState хранит last_read_at (timestamp)
|
||||
# Проблема: несколько сообщений могут иметь одинаковый timestamp
|
||||
```
|
||||
|
||||
**Правильно:**
|
||||
```python
|
||||
# Хранить last_read_message_id
|
||||
# Позволяет точно позиционировать скролл
|
||||
```
|
||||
|
||||
### 3. **WebSocket дублирует HTTP загрузку**
|
||||
|
||||
**Текущая проблема:**
|
||||
```javascript
|
||||
// 1. ChatController загружает через HTTP
|
||||
await loader.loadInitialMessages() // → 50 сообщений
|
||||
|
||||
// 2. WebSocket присылает те же сообщения
|
||||
ws.send({action: 'open_chat'}) // → initial_messages с теми же 50
|
||||
```
|
||||
|
||||
**Результат:** Двойная загрузка, повторный render, прыжки скролла
|
||||
|
||||
**Правильно:**
|
||||
- WebSocket НЕ должен отправлять initial messages
|
||||
- Initial загрузка ТОЛЬКО через HTTP
|
||||
- WS только для real-time обновлений
|
||||
|
||||
### 4. **Нет bidirectional pagination**
|
||||
|
||||
**Современные мессенджеры:**
|
||||
```
|
||||
┌─────────────────────────┐
|
||||
│ ↑ Scroll up = load more │ ← Бесконечный скролл вверх
|
||||
├─────────────────────────┤
|
||||
│ [Сообщения вокруг] │ ← Initial load вокруг last_read
|
||||
├─────────────────────────┤
|
||||
│ ↓ Scroll down = load │ ← Если есть более новые
|
||||
└─────────────────────────┘
|
||||
```
|
||||
|
||||
**Наша система:**
|
||||
```
|
||||
┌─────────────────────────┐
|
||||
│ ↑ Scroll up = load more │ ← Работает
|
||||
├─────────────────────────┤
|
||||
│ [Последние 50] │ ← Всегда загружаются последние
|
||||
├─────────────────────────┤
|
||||
│ ✗ No scroll down load │ ← НЕТ
|
||||
└─────────────────────────┘
|
||||
```
|
||||
|
||||
## ✅ Решение
|
||||
|
||||
### Фаза 1: Backend - новый endpoint (5 минут)
|
||||
|
||||
```python
|
||||
# backend/api/v1/communications/views.py
|
||||
|
||||
@login_required
|
||||
@require_GET
|
||||
def load_chat_messages_around(request, pk: int):
|
||||
"""Загрузка сообщений ВОКРУГ конкретного message_id"""
|
||||
chat = get_object_or_404(Chat, pk=pk)
|
||||
user = request.user
|
||||
|
||||
if not user_can_access_chat(chat, user):
|
||||
return JsonResponse({"ok": False, "error": "forbidden"}, status=403)
|
||||
|
||||
# Параметры
|
||||
around_id = request.GET.get("around_id") # ID сообщения-центра
|
||||
limit = int(request.GET.get("limit", 50))
|
||||
half = limit // 2
|
||||
|
||||
if not around_id:
|
||||
# Если нет around_id - используем last_read
|
||||
read_state = ChatReadState.objects.filter(
|
||||
chat=chat, user=user
|
||||
).first()
|
||||
|
||||
if read_state and read_state.last_read_message_id:
|
||||
around_id = read_state.last_read_message_id
|
||||
else:
|
||||
# Нет last_read - загружаем последние
|
||||
return load_chat_messages(request, pk)
|
||||
|
||||
# Загружаем around
|
||||
anchor = Message.objects.filter(chat=chat, pk=around_id).first()
|
||||
if not anchor:
|
||||
return load_chat_messages(request, pk)
|
||||
|
||||
# Сообщения ДО anchor
|
||||
before_qs = (
|
||||
Message.objects.filter(
|
||||
chat=chat, created_at__lt=anchor.created_at
|
||||
)
|
||||
.order_by("-created_at")[:half]
|
||||
)
|
||||
before_messages = list(reversed(list(before_qs)))
|
||||
|
||||
# Сообщения ПОСЛЕ anchor (включая само)
|
||||
after_qs = (
|
||||
Message.objects.filter(
|
||||
chat=chat, created_at__gte=anchor.created_at
|
||||
)
|
||||
.order_by("created_at")[: half + 1]
|
||||
)
|
||||
after_messages = list(after_qs)
|
||||
|
||||
# Объединяем
|
||||
all_messages = before_messages + after_messages
|
||||
serialized = [serialize_message(m) for m in all_messages]
|
||||
|
||||
return JsonResponse({
|
||||
"ok": True,
|
||||
"messages": serialized,
|
||||
"anchor_id": int(around_id),
|
||||
"has_more_before": len(before_messages) == half,
|
||||
"has_more_after": len(after_messages) > half
|
||||
})
|
||||
```
|
||||
|
||||
### Фаза 2: Frontend - использовать новый endpoint (10 минут)
|
||||
|
||||
```javascript
|
||||
// messageLoader.js
|
||||
|
||||
async loadInitialMessages(chatId, options = {}) {
|
||||
// Проверяем есть ли lastReadMessageId
|
||||
const lastReadId = options.lastReadMessageId ||
|
||||
this._getLastReadFromStorage(chatId);
|
||||
|
||||
let url;
|
||||
if (lastReadId) {
|
||||
// Загружаем ВОКРУГ last read
|
||||
url = `/api/v1/communications/chats/${chatId}/messages/around/`;
|
||||
url += `?around_id=${lastReadId}&limit=50`;
|
||||
} else {
|
||||
// Загружаем последние
|
||||
url = `/api/v1/communications/chats/${chatId}/messages/`;
|
||||
url += `?limit=50`;
|
||||
}
|
||||
|
||||
const response = await fetch(url);
|
||||
const data = await response.json();
|
||||
|
||||
// Сохраняем в Store
|
||||
messages.forEach(msg => msg.chat_id = chatId);
|
||||
this.store.addMessages(data.messages);
|
||||
|
||||
return {
|
||||
messages: data.messages,
|
||||
anchorId: data.anchor_id, // На какое сообщение скроллить
|
||||
hasMoreBefore: data.has_more_before,
|
||||
hasMoreAfter: data.has_more_after
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### Фаза 3: Обновить ChatReadState (20 минут)
|
||||
|
||||
```python
|
||||
# Добавить поле last_read_message_id в модель
|
||||
class ChatReadState(models.Model):
|
||||
chat = models.ForeignKey(Chat, on_delete=models.CASCADE)
|
||||
user = models.ForeignKey(User, on_delete=models.CASCADE)
|
||||
last_read_at = models.DateTimeField() # Deprecated
|
||||
last_read_message_id = models.BigIntegerField(null=True) # NEW
|
||||
```
|
||||
|
||||
## 📊 Результат
|
||||
|
||||
**До:**
|
||||
```
|
||||
Открытие чата → Загрузка последних 50 → Скролл вниз → Прыжок
|
||||
```
|
||||
|
||||
**После:**
|
||||
```
|
||||
Открытие чата → Загрузка 25 до + 25 после last_read →
|
||||
Скролл уже на нужной позиции → Без прыжков
|
||||
```
|
||||
|
||||
## 🎯 Приоритет
|
||||
|
||||
1. **Критично:** Endpoint `messages/around/` - 5 минут
|
||||
2. **Критично:** Frontend использует around - 10 минут
|
||||
3. **Средне:** Обновить ChatReadState с message_id - 20 минут
|
||||
4. **Низко:** Bidirectional scroll (load after) - 30 минут
|
||||
|
||||
## 🔗 Примеры из реальных мессенджеров
|
||||
|
||||
**Telegram API:**
|
||||
```
|
||||
messages.getHistory(
|
||||
peer,
|
||||
offset_id: int, // ID сообщения-якоря
|
||||
add_offset: -25, // -25 = загрузить 25 выше
|
||||
limit: 50 // Всего 50 сообщений
|
||||
)
|
||||
// Возвращает 25 до + 25 после offset_id
|
||||
```
|
||||
|
||||
**Discord API:**
|
||||
```
|
||||
GET /channels/{id}/messages?around={message_id}&limit=50
|
||||
// Возвращает сообщения вокруг указанного
|
||||
```
|
||||
|
||||
**WhatsApp:**
|
||||
- Хранит last_read_message_id
|
||||
- При открытии загружает 40 сообщений вокруг него
|
||||
- Скролл автоматически на last_read
|
||||
256
backend/docs/testing/CHAT_TESTS_GUIDE.md
Normal file
256
backend/docs/testing/CHAT_TESTS_GUIDE.md
Normal file
@ -0,0 +1,256 @@
|
||||
# Руководство по тестированию системы чата
|
||||
|
||||
## 📋 Обзор
|
||||
|
||||
Комплексная система тестирования для всех компонентов чата EUSRR. Тесты написаны на чистом JavaScript и могут быть запущены прямо в консоли браузера без дополнительных инструментов.
|
||||
|
||||
## 🚀 Способы запуска тестов
|
||||
|
||||
### Способ 1: Через HTML интерфейс (Рекомендуется)
|
||||
|
||||
1. Откройте файл в браузере:
|
||||
```
|
||||
http://localhost:8000/static/js/tests/runTests.html
|
||||
```
|
||||
|
||||
2. Нажмите на кнопку для запуска нужного набора тестов:
|
||||
- **🚀 Запустить все тесты** - полный набор всех тестов
|
||||
- **📦 MessageStore** - тесты хранилища сообщений
|
||||
- **📥 MessageLoader** - тесты загрузчика сообщений
|
||||
- **📜 ScrollManager** - тесты менеджера скролла
|
||||
- **🎨 MessageRenderer** - тесты рендерера сообщений
|
||||
- **🎮 ChatController** - тесты контроллера чата
|
||||
- **🔗 Интеграционные** - тесты взаимодействия компонентов
|
||||
- **⚡ Производительность** - тесты производительности
|
||||
|
||||
3. Результаты отображаются:
|
||||
- В интерфейсе страницы
|
||||
- В консоли браузера (F12)
|
||||
|
||||
### Способ 2: Из консоли браузера на странице чата
|
||||
|
||||
1. Откройте страницу чата в браузере
|
||||
2. Откройте консоль разработчика (F12)
|
||||
3. Загрузите тестовый модуль:
|
||||
```javascript
|
||||
const script = document.createElement('script');
|
||||
script.src = '/static/js/tests/chatTests.js';
|
||||
script.type = 'module';
|
||||
document.head.appendChild(script);
|
||||
```
|
||||
|
||||
4. Запустите тесты:
|
||||
```javascript
|
||||
// Все тесты
|
||||
await window.ChatTests.runAll()
|
||||
|
||||
// Отдельные модули
|
||||
await window.ChatTests.testMessageStore()
|
||||
await window.ChatTests.testMessageLoader()
|
||||
await window.ChatTests.testScrollManager()
|
||||
await window.ChatTests.testMessageRenderer()
|
||||
await window.ChatTests.testChatController()
|
||||
await window.ChatTests.testIntegration()
|
||||
await window.ChatTests.testPerformance()
|
||||
```
|
||||
|
||||
### Способ 3: Скопировать код в консоль
|
||||
|
||||
1. Откройте файл `/static/js/tests/chatTests.js`
|
||||
2. Скопируйте весь код
|
||||
3. Вставьте в консоль браузера на странице чата
|
||||
4. Запустите: `await window.ChatTests.runAll()`
|
||||
|
||||
## 📊 Что тестируется?
|
||||
|
||||
### MessageStore (Хранилище сообщений)
|
||||
- ✅ Добавление одного сообщения
|
||||
- ✅ Обработка дубликатов
|
||||
- ✅ Массовое добавление сообщений
|
||||
- ✅ Получение сообщения по ID
|
||||
- ✅ Проверка наличия сообщения
|
||||
- ✅ Получение старейшего/новейшего сообщения
|
||||
- ✅ Обновление сообщения
|
||||
- ✅ Удаление сообщения
|
||||
- ✅ Очистка чата
|
||||
- ✅ Оптимистичные сообщения
|
||||
- ✅ События Store (messagesAdded, messageUpdated, etc.)
|
||||
|
||||
### MessageLoader (Загрузчик сообщений)
|
||||
- ✅ Инициализация
|
||||
- ✅ Состояния загрузки
|
||||
- ✅ Состояния чата (hasMore, oldestId)
|
||||
- ✅ Обработка WebSocket событий (новое сообщение, редактирование, удаление)
|
||||
- ✅ Обработка реакций
|
||||
- ✅ Оптимистичная отправка
|
||||
|
||||
### ScrollManager (Менеджер скролла)
|
||||
- ✅ Инициализация
|
||||
- ✅ Скролл вниз
|
||||
- ✅ Скролл к сообщению
|
||||
- ✅ Сохранение/восстановление позиции
|
||||
- ✅ IntersectionObserver для lazy loading истории
|
||||
|
||||
### MessageRenderer (Рендерер сообщений)
|
||||
- ✅ Инициализация
|
||||
- ✅ Рендеринг сообщения
|
||||
- ✅ Обновление сообщения
|
||||
- ✅ Рендеринг собственного сообщения
|
||||
- ✅ Удаление сообщения
|
||||
- ✅ Очистка контейнера
|
||||
|
||||
### ChatController (Контроллер чата)
|
||||
- ✅ Инициализация всех компонентов
|
||||
- ✅ Наличие всех методов (init, loadChat, sendMessage, etc.)
|
||||
- ✅ Event listeners
|
||||
- ✅ Оптимистичная отправка
|
||||
|
||||
### Интеграционные тесты
|
||||
- ✅ Полный цикл жизни сообщения (добавление → редактирование → удаление)
|
||||
- ✅ Множественные сообщения
|
||||
- ✅ Оптимистичная отправка с подтверждением
|
||||
- ✅ События Store → Renderer
|
||||
|
||||
### Тесты производительности
|
||||
- ✅ Массовое добавление (1000 сообщений)
|
||||
- ✅ Поиск сообщений (100 запросов)
|
||||
- ✅ Рендеринг большого количества сообщений
|
||||
- ✅ Массовое обновление
|
||||
|
||||
## 📈 Интерпретация результатов
|
||||
|
||||
### Успешный запуск
|
||||
```
|
||||
🏁 ИТОГОВЫЕ РЕЗУЛЬТАТЫ
|
||||
============================================================
|
||||
Всего тестов: 87
|
||||
✅ Успешно: 87
|
||||
❌ Провалено: 0
|
||||
📈 Процент успеха: 100.0%
|
||||
============================================================
|
||||
```
|
||||
|
||||
### С ошибками
|
||||
```
|
||||
🏁 ИТОГОВЫЕ РЕЗУЛЬТАТЫ
|
||||
============================================================
|
||||
Всего тестов: 87
|
||||
✅ Успешно: 82
|
||||
❌ Провалено: 5
|
||||
📈 Процент успеха: 94.3%
|
||||
============================================================
|
||||
|
||||
❌ Проваленные тесты:
|
||||
- Добавление дубликата должно вернуть 0 (expected: 0, got: 1)
|
||||
- Должно быть отрендерено 10 сообщений (expected: 10, got: 9)
|
||||
...
|
||||
```
|
||||
|
||||
## 🔧 Добавление новых тестов
|
||||
|
||||
Структура теста:
|
||||
|
||||
```javascript
|
||||
async testYourFeature() {
|
||||
console.log('\n🧪 Тестирование YourFeature...\n');
|
||||
this.resetResults();
|
||||
|
||||
// Подготовка
|
||||
const component = new YourComponent();
|
||||
|
||||
// Тест 1
|
||||
const result = component.doSomething();
|
||||
this.assertEqual(result, expectedValue, 'Описание теста');
|
||||
|
||||
// Тест 2
|
||||
this.assert(condition, 'Условие должно быть истинным');
|
||||
|
||||
// Тест 3
|
||||
this.assertNotNull(value, 'Значение не должно быть null');
|
||||
|
||||
this.printResults();
|
||||
return this.results;
|
||||
}
|
||||
```
|
||||
|
||||
Добавьте в `runAll()`:
|
||||
```javascript
|
||||
const yourResults = await this.testYourFeature();
|
||||
```
|
||||
|
||||
Экспортируйте:
|
||||
```javascript
|
||||
window.ChatTests = {
|
||||
// ...
|
||||
testYourFeature: () => chatTests.testYourFeature()
|
||||
};
|
||||
```
|
||||
|
||||
## 🐛 Отладка проваленных тестов
|
||||
|
||||
1. **Проверьте консоль браузера** - там будет детальная информация об ошибках
|
||||
2. **Запустите конкретный тест** - `window.ChatTests.testMessageStore()` вместо `runAll()`
|
||||
3. **Добавьте console.log** в проваленный тест для отладки
|
||||
4. **Проверьте DOM** - возможно, элементы не создаются корректно
|
||||
5. **Проверьте timing** - добавьте `await this.sleep(100)` если нужно время на рендеринг
|
||||
|
||||
## 📝 Примеры использования
|
||||
|
||||
### Быстрая проверка после изменений
|
||||
```javascript
|
||||
// После изменения MessageStore
|
||||
await window.ChatTests.testMessageStore()
|
||||
|
||||
// После изменения рендеринга
|
||||
await window.ChatTests.testMessageRenderer()
|
||||
```
|
||||
|
||||
### Проверка производительности
|
||||
```javascript
|
||||
// Запускайте периодически для мониторинга
|
||||
await window.ChatTests.testPerformance()
|
||||
```
|
||||
|
||||
### Полная проверка перед коммитом
|
||||
```javascript
|
||||
// Все тесты должны пройти успешно
|
||||
const results = await window.ChatTests.runAll()
|
||||
console.log(`Готов к коммиту: ${results.failed === 0}`)
|
||||
```
|
||||
|
||||
## 🎯 Best Practices
|
||||
|
||||
1. **Запускайте тесты регулярно** - после каждого значимого изменения
|
||||
2. **Проверяйте производительность** - следите за регрессией скорости
|
||||
3. **Добавляйте тесты для новых фич** - покрывайте новый код тестами
|
||||
4. **Не коммитьте без тестов** - все тесты должны проходить
|
||||
5. **Используйте HTML интерфейс** - удобнее для повседневной работы
|
||||
|
||||
## 🔍 Troubleshooting
|
||||
|
||||
### Тесты не загружаются
|
||||
- Убедитесь, что Django сервер запущен
|
||||
- Проверьте путь к файлу: `/static/js/tests/chatTests.js`
|
||||
- Откройте консоль и проверьте ошибки загрузки модулей
|
||||
|
||||
### Тесты падают с ошибками модулей
|
||||
- Убедитесь, что вы на странице чата (модули должны быть доступны)
|
||||
- Проверьте, что все зависимости импортированы корректно
|
||||
- Очистите кэш браузера (Ctrl+Shift+R)
|
||||
|
||||
### Производительность тестов низкая
|
||||
- Проверьте производительность в режиме инкогнито (без расширений)
|
||||
- Используйте последнюю версию браузера
|
||||
- Закройте другие вкладки браузера
|
||||
|
||||
## 📞 Поддержка
|
||||
|
||||
При возникновении проблем:
|
||||
1. Проверьте консоль браузера на ошибки
|
||||
2. Запустите тесты по отдельности для локализации проблемы
|
||||
3. Проверьте, что все модули чата загружены корректно
|
||||
4. Убедитесь, что используете актуальную версию кода
|
||||
|
||||
---
|
||||
|
||||
**Последнее обновление:** 12 января 2026
|
||||
229
backend/docs/testing/DUPLICATE_LOGIC_FIXES_TESTING.md
Normal file
229
backend/docs/testing/DUPLICATE_LOGIC_FIXES_TESTING.md
Normal file
@ -0,0 +1,229 @@
|
||||
# Тестирование исправлений дублирующей логики
|
||||
|
||||
**Дата:** 12.01.2026
|
||||
**Контекст:** Исправлены 2 критические проблемы дублирования логики
|
||||
|
||||
## Что было исправлено
|
||||
|
||||
### 1. ✅ MessageContextMenu - убрана двойная обработка
|
||||
- **Было:** MutationObserver + Event listener = каждое сообщение обрабатывалось дважды
|
||||
- **Стало:** Только Event listener на `chat:message-added`
|
||||
|
||||
### 2. ✅ Day Dividers - унифицирован метод создания
|
||||
- **Было:** chatHistoryLoader использовал старый `createDayDivider` (минимальный стиль)
|
||||
- **Стало:** Все используют messageRenderer (единый стиль с фоном)
|
||||
|
||||
### 3. ✅ Убраны неиспользуемые импорты
|
||||
- userWebSocket.js больше не импортирует `createDayDivider`, `createMessageElement`
|
||||
|
||||
---
|
||||
|
||||
## Чек-лист тестирования
|
||||
|
||||
### Тест 1: Контекстное меню (БЕЗ дублей)
|
||||
|
||||
**Шаги:**
|
||||
1. Открыть чат с сообщениями
|
||||
2. Открыть DevTools Console (F12)
|
||||
3. Перезагрузить страницу (Ctrl+R)
|
||||
|
||||
**Ожидаемый результат:**
|
||||
```
|
||||
✅ [MessageContextMenu] Observer setup skipped - using event-based model only
|
||||
✅ НЕТ дублирующих логов типа:
|
||||
[MessageContextMenu] New message detected: 434
|
||||
[MessageContextMenu] attachToMessage called: {messageId: '434'...}
|
||||
[MessageContextMenu] All event listeners attached to message: 434
|
||||
[MessageContextMenu] New message detected: 434 ← ДУБЛЬ (не должно быть!)
|
||||
```
|
||||
|
||||
**Проверка функциональности:**
|
||||
- [ ] Правый клик на сообщении → открывается контекстное меню
|
||||
- [ ] Выбор "Ответить" → работает
|
||||
- [ ] Добавление реакции → работает
|
||||
- [ ] Клик вне меню → меню закрывается
|
||||
|
||||
---
|
||||
|
||||
### Тест 2: Разделители дат (ЕДИНЫЙ стиль)
|
||||
|
||||
**Шаги:**
|
||||
1. Открыть чат с длинной историей (несколько дней сообщений)
|
||||
2. Посмотреть на разделители дат в начале чата
|
||||
3. Scroll UP для загрузки истории
|
||||
4. Посмотреть на НОВЫЕ разделители дат
|
||||
|
||||
**Ожидаемый результат:**
|
||||
- [ ] ✅ ВСЕ разделители дат имеют ОДИНАКОВЫЙ стиль:
|
||||
- Центрированный текст
|
||||
- Серый цвет (`text-muted`)
|
||||
- Фон (`bg-light rounded-pill`)
|
||||
- Отступы (`my-3`)
|
||||
|
||||
**НЕправильный результат (старая проблема):**
|
||||
- ❌ Некоторые dividers простые (только текст)
|
||||
- ❌ Некоторые dividers с фоном
|
||||
- ❌ Разные стили в одном чате
|
||||
|
||||
---
|
||||
|
||||
### Тест 3: История сообщений (scroll up)
|
||||
|
||||
**Шаги:**
|
||||
1. Открыть чат
|
||||
2. Scroll UP до упора
|
||||
3. Подождать загрузки старых сообщений
|
||||
4. Проверить что dividers создаются автоматически
|
||||
|
||||
**Ожидаемый результат:**
|
||||
- [ ] История загружается корректно
|
||||
- [ ] Разделители дат создаются для истории
|
||||
- [ ] Стиль разделителей ТАКОЙ ЖЕ как у новых
|
||||
- [ ] Scroll остается в том же месте (не прыгает вверх)
|
||||
|
||||
---
|
||||
|
||||
### Тест 4: Новые сообщения в реальном времени
|
||||
|
||||
**Шаги:**
|
||||
1. Открыть чат в ДВУХ браузерах/вкладках
|
||||
2. В одной вкладке отправить сообщение
|
||||
3. В другой вкладке проверить:
|
||||
|
||||
**Ожидаемый результат:**
|
||||
- [ ] Сообщение появляется мгновенно
|
||||
- [ ] Если сообщение в новом дне → создается day-divider
|
||||
- [ ] Стиль divider'а правильный (с фоном)
|
||||
- [ ] В консоли НЕТ дублирующих логов MessageContextMenu
|
||||
- [ ] Контекстное меню работает на новом сообщении
|
||||
|
||||
---
|
||||
|
||||
### Тест 5: Пустой чат
|
||||
|
||||
**Шаги:**
|
||||
1. Создать новый чат (или открыть пустой)
|
||||
2. Открыть консоль
|
||||
|
||||
**Ожидаемый результат:**
|
||||
- [ ] Loader исчезает
|
||||
- [ ] Показывается сообщение "Начните общение"
|
||||
- [ ] НЕТ ошибок в консоли
|
||||
- [ ] НЕТ бесконечного loader'а
|
||||
|
||||
---
|
||||
|
||||
### Тест 6: Производительность
|
||||
|
||||
**Шаги:**
|
||||
1. Открыть чат с большим количеством сообщений (100+)
|
||||
2. Открыть DevTools → Performance
|
||||
3. Начать запись
|
||||
4. Перезагрузить страницу
|
||||
5. Остановить запись когда чат загрузится
|
||||
|
||||
**Ожидаемый результат:**
|
||||
- [ ] Загрузка занимает < 1 секунды
|
||||
- [ ] НЕТ долгих задач (Long Tasks)
|
||||
- [ ] НЕТ layout thrashing
|
||||
- [ ] Scroll performance хороший (no jank)
|
||||
|
||||
---
|
||||
|
||||
## Проблемы которые ЕЩЕ ОСТАЛИСЬ
|
||||
|
||||
### ⚠️ Прыжки при прокрутке
|
||||
|
||||
**Статус:** НЕ исправлено в этом коммите
|
||||
|
||||
**Описание:**
|
||||
Страница загружается, но потом визуально "прыгает" к последнему сообщению.
|
||||
|
||||
**Тест:**
|
||||
1. Открыть чат
|
||||
2. ВНИМАТЕЛЬНО смотреть на момент загрузки
|
||||
3. Заметить прыжок scroll'а
|
||||
|
||||
**Возможные причины:**
|
||||
- CSS transitions вызывают reflow
|
||||
- Browser paint происходит ПОСЛЕ установки scrollTop
|
||||
- Нужен двойной requestAnimationFrame
|
||||
- Что-то в base.html делает дополнительный scroll
|
||||
|
||||
**След действия:** См. [backend/docs/reports/DUPLICATE_LOGIC_AUDIT.md](../reports/DUPLICATE_LOGIC_AUDIT.md) раздел "Что еще проверить"
|
||||
|
||||
---
|
||||
|
||||
## Регрессионное тестирование
|
||||
|
||||
После всех тестов проверить что НЕ СЛОМАЛОСЬ:
|
||||
|
||||
### Основные функции чата:
|
||||
- [ ] Отправка сообщений
|
||||
- [ ] Отправка файлов/изображений
|
||||
- [ ] Редактирование сообщений
|
||||
- [ ] Удаление сообщений
|
||||
- [ ] @упоминания пользователей
|
||||
- [ ] Реакции (emoji)
|
||||
- [ ] Ответы (reply)
|
||||
- [ ] Форвард сообщений
|
||||
- [ ] Закрепленные сообщения
|
||||
- [ ] Polls (голосования)
|
||||
|
||||
### UI/UX:
|
||||
- [ ] Кнопка scroll to bottom
|
||||
- [ ] Unread divider (разделитель непрочитанных)
|
||||
- [ ] Typing indicator ("печатает...")
|
||||
- [ ] Read receipts (прочитано)
|
||||
- [ ] Online status
|
||||
- [ ] Avatar отображение
|
||||
- [ ] Mobile responsive
|
||||
|
||||
### WebSocket:
|
||||
- [ ] Подключение устанавливается
|
||||
- [ ] Реконнект после disconnect
|
||||
- [ ] Сообщения приходят в реальном времени
|
||||
- [ ] Уведомления работают
|
||||
- [ ] Badge counter обновляется
|
||||
|
||||
---
|
||||
|
||||
## Как сообщить о проблеме
|
||||
|
||||
Если что-то работает неправильно:
|
||||
|
||||
1. **Скриншот или видео** проблемы
|
||||
2. **Шаги для воспроизведения**
|
||||
3. **Консоль DevTools** (скопировать логи)
|
||||
4. **Network tab** (если проблема с загрузкой)
|
||||
5. **Браузер и версия**
|
||||
|
||||
**Пример хорошего bug report:**
|
||||
|
||||
```
|
||||
ПРОБЛЕМА: Day-dividers все еще разные стили
|
||||
|
||||
Шаги:
|
||||
1. Открыл чат #123
|
||||
2. Scroll up для истории
|
||||
3. Вижу что старые dividers БЕЗ фона, новые С фоном
|
||||
|
||||
Консоль: (нет ошибок)
|
||||
|
||||
Браузер: Chrome 131.0.6778.109
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Финальный чек перед деплоем
|
||||
|
||||
- [x] collectstatic выполнен успешно
|
||||
- [x] Нет ошибок JavaScript в get_errors
|
||||
- [x] Код отформатирован
|
||||
- [ ] Все тесты из чек-листа пройдены
|
||||
- [ ] Регрессионное тестирование пройдено
|
||||
- [ ] Обновлена документация
|
||||
- [ ] Коммит запушен в Git
|
||||
|
||||
**Статус:** 🟡 READY FOR TESTING (не все тесты пройдены)
|
||||
|
||||
@ -42,19 +42,15 @@ body:has(.chat-page) .bottom-navbar {
|
||||
}
|
||||
}
|
||||
|
||||
/* На десктопе добавляем container для контента */
|
||||
@media (min-width: 768px) {
|
||||
.chat-page {
|
||||
padding: 0 var(--space-3);
|
||||
}
|
||||
|
||||
|
||||
|
||||
.chat-header,
|
||||
.chat-container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
|
||||
.chat-container {
|
||||
border-radius: var(--bs-border-radius-lg);
|
||||
border: 1px solid var(--bs-border-color);
|
||||
@ -70,19 +66,22 @@ body:has(.chat-page) .bottom-navbar {
|
||||
flex-shrink: 0;
|
||||
background: var(--bs-body-bg);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
z-index: 10;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1020;
|
||||
border-bottom: 1px solid var(--bs-border-color);
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.chat-header {
|
||||
padding: var(--space-3) var(--space-4);
|
||||
border-bottom: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 767.98px) {
|
||||
.chat-header {
|
||||
padding: var(--space-1) var(--space-2);
|
||||
border-bottom: 1px solid var(--bs-border-color);
|
||||
}
|
||||
}
|
||||
|
||||
@ -161,15 +160,15 @@ body:has(.chat-page) .bottom-navbar {
|
||||
.chat-header {
|
||||
padding: var(--space-1) var(--space-2);
|
||||
}
|
||||
|
||||
|
||||
.chat-header-title h1 {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
|
||||
.chat-header-title i {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
|
||||
.chat-header-actions .btn-sm span {
|
||||
display: none;
|
||||
}
|
||||
@ -188,6 +187,32 @@ body:has(.chat-page) .bottom-navbar {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════
|
||||
EMOJI PICKER
|
||||
═══════════════════════════════════════════════════════════ */
|
||||
|
||||
.chat-emoji-picker {
|
||||
position: absolute;
|
||||
bottom: 100%;
|
||||
right: 0;
|
||||
margin-bottom: var(--space-2);
|
||||
z-index: 1050;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
border-radius: var(--bs-border-radius-lg);
|
||||
max-width: 90vw;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.chat-emoji-picker {
|
||||
right: var(--space-3);
|
||||
}
|
||||
}
|
||||
|
||||
/* Скрываем по умолчанию, показываем через JS */
|
||||
.chat-emoji-picker:not([data-visible="true"]) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════
|
||||
ОБЛАСТЬ СООБЩЕНИЙ
|
||||
═══════════════════════════════════════════════════════════ */
|
||||
@ -201,6 +226,15 @@ body:has(.chat-page) .bottom-navbar {
|
||||
-webkit-overflow-scrolling: touch;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: color-mix(in srgb, var(--bs-body-color), transparent 70%) transparent;
|
||||
|
||||
/* CSS Containment для оптимизации (как в Telegram) */
|
||||
contain: layout style paint;
|
||||
will-change: scroll-position;
|
||||
|
||||
/* GPU acceleration для плавного scroll */
|
||||
transform: translateZ(0);
|
||||
backface-visibility: hidden;
|
||||
perspective: 1000px;
|
||||
}
|
||||
|
||||
.chat-messages-scroll:hover {
|
||||
@ -268,6 +302,8 @@ body:has(.chat-page) .bottom-navbar {
|
||||
padding-bottom: calc(var(--space-4) + env(safe-area-inset-bottom, 0px));
|
||||
background: var(--bs-body-bg);
|
||||
border-top: 1px solid var(--bs-border-color);
|
||||
position: relative;
|
||||
z-index: 1010;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
@ -281,9 +317,7 @@ body:has(.chat-page) .bottom-navbar {
|
||||
@media (max-width: 767.98px) {
|
||||
.chat-composer-wrapper {
|
||||
padding: var(--space-2) var(--space-2);
|
||||
padding-bottom: calc(var(--space-5) + env(safe-area-inset-bottom, 12px));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.message-field {
|
||||
display: flex;
|
||||
@ -408,8 +442,8 @@ body:has(.chat-page) .bottom-navbar {
|
||||
}
|
||||
|
||||
.bubble-me {
|
||||
background: var(--bs-primary);
|
||||
color: white;
|
||||
background: color-mix(in srgb, var(--bs-primary) 92%, #fff 0%);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.bubble-other {
|
||||
@ -420,11 +454,10 @@ body:has(.chat-page) .bottom-navbar {
|
||||
.mini-ava {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
border-radius: 9999px;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: var(--bs-secondary-bg);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
@ -433,6 +466,7 @@ body:has(.chat-page) .bottom-navbar {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════
|
||||
@ -476,9 +510,12 @@ body:has(.chat-page) .bottom-navbar {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-1);
|
||||
margin: var(--space-2) 0;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
margin: var(--space-1) 0 var(--space-2);
|
||||
font-size: .85rem;
|
||||
line-height: 1;
|
||||
font-weight: 700;
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.day-divider::before,
|
||||
@ -486,13 +523,20 @@ body:has(.chat-page) .bottom-navbar {
|
||||
.unread-divider::before,
|
||||
.unread-divider::after {
|
||||
content: "";
|
||||
flex: 1;
|
||||
flex: 1 1 auto;
|
||||
height: 1px;
|
||||
background: var(--bs-border-color);
|
||||
background: color-mix(in srgb, var(--bs-border-color), transparent 30%);
|
||||
}
|
||||
|
||||
.day-divider > span,
|
||||
.unread-divider > span {
|
||||
flex: 0 0 auto;
|
||||
padding: 0 var(--space-1);
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.day-divider {
|
||||
color: var(--bs-secondary);
|
||||
color: var(--bs-secondary-color);
|
||||
}
|
||||
|
||||
.unread-divider {
|
||||
|
||||
72
backend/static/js/chat/index.js
Normal file
72
backend/static/js/chat/index.js
Normal file
@ -0,0 +1,72 @@
|
||||
/**
|
||||
* @fileoverview Chat Module Index - точка входа для всех модулей чата
|
||||
* @module chat/index
|
||||
*
|
||||
* Экспортирует все V2 модули для удобного импорта:
|
||||
*
|
||||
* import { ChatControllerV2, MessageStoreV2 } from './chat/index.js';
|
||||
*/
|
||||
|
||||
// Конфигурация
|
||||
export {
|
||||
LOADER_CONFIG,
|
||||
SCROLL_CONFIG,
|
||||
RENDER_CONFIG,
|
||||
WS_CONFIG,
|
||||
STORE_EVENTS,
|
||||
CHAT_EVENTS,
|
||||
WS_EVENTS,
|
||||
MESSAGE_STATUS,
|
||||
API_ENDPOINTS,
|
||||
buildUrl,
|
||||
getRequestHeaders
|
||||
} from '../config/chatConfig.js';
|
||||
|
||||
// Store
|
||||
export { MessageStoreV2 } from '../stores/messageStoreV2.js';
|
||||
export { MessageStore } from '../stores/messageStore.js'; // Legacy
|
||||
|
||||
// Loader
|
||||
export { MessageLoaderV2 } from '../loaders/messageLoaderV2.js';
|
||||
export { MessageLoader } from '../loaders/messageLoader.js'; // Legacy
|
||||
|
||||
// Renderer
|
||||
export { MessageRendererV2 } from '../renderers/messageRendererV2.js';
|
||||
|
||||
// Managers
|
||||
export { ScrollManagerV2 } from '../managers/scrollManagerV2.js';
|
||||
export { ScrollManager } from '../managers/scrollManager.js'; // Legacy
|
||||
|
||||
// Controllers
|
||||
export { ChatControllerV2 } from '../controllers/chatControllerV2.js';
|
||||
export { ChatController } from '../controllers/chatController.js'; // Legacy
|
||||
|
||||
/**
|
||||
* Создает экземпляр ChatController с рекомендуемыми настройками
|
||||
* @param {Object} options - Опции
|
||||
* @returns {ChatControllerV2}
|
||||
*/
|
||||
export function createChatController(options) {
|
||||
return new (ChatControllerV2)(options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Версия модуля
|
||||
*/
|
||||
export const VERSION = '2.0.0';
|
||||
|
||||
/**
|
||||
* Информация о модуле
|
||||
*/
|
||||
export const MODULE_INFO = {
|
||||
name: 'EUSRR Chat Module',
|
||||
version: VERSION,
|
||||
description: 'Модуль загрузки и управления чатом',
|
||||
components: [
|
||||
'ChatControllerV2',
|
||||
'MessageStoreV2',
|
||||
'MessageLoaderV2',
|
||||
'ScrollManagerV2',
|
||||
'MessageRendererV2'
|
||||
]
|
||||
};
|
||||
@ -51,7 +51,8 @@ class ChatComposer {
|
||||
this.meAvatar = options.meAvatar || form.dataset.meAvatar || this.scrollEl?.dataset.meAvatar || '';
|
||||
this.profileUrl = options.profileUrl || form.dataset.profileUrl || this.options.profileUrl;
|
||||
this.detailUrlTemplate = options.detailUrlTemplate || form.dataset.detailUrlTemplate || this.options.detailUrlTemplate;
|
||||
this.messageRenderer = options.messageRenderer || null; // РЕФАКТОРИНГ: Принимаем MessageRenderer
|
||||
this.messageRenderer = options.messageRenderer || null; // DEPRECATED: Старый MessageRenderer
|
||||
this.chatController = options.chatController || null; // НОВОЕ: ChatController для новой архитектуры
|
||||
this.selectedFiles = [];
|
||||
this.typingThrottle = 1200;
|
||||
this.lastTypingSent = 0;
|
||||
@ -547,7 +548,24 @@ class ChatComposer {
|
||||
is_pending: true
|
||||
};
|
||||
|
||||
// РЕФАКТОРИНГ: Используем MessageRenderer если доступен, иначе fallback на createMessageElement
|
||||
// НОВАЯ АРХИТЕКТУРА: Используем ChatController для оптимистичных сообщений
|
||||
// ChatController сам создаст pending message и добавит в Store
|
||||
if (this.chatController) {
|
||||
const tempId = this.chatController.sendMessage(content, {
|
||||
attachments,
|
||||
optimistic: true
|
||||
});
|
||||
|
||||
this.pendingQueue.push({
|
||||
id: pendingId,
|
||||
tempId: tempId, // ID от ChatController
|
||||
objectUrls: attachments.map((att) => att.file_url)
|
||||
});
|
||||
|
||||
return pendingId;
|
||||
}
|
||||
|
||||
// FALLBACK: Старая логика с MessageRenderer (для backward compatibility)
|
||||
let element;
|
||||
if (this.messageRenderer) {
|
||||
element = this.messageRenderer.createMessageElement(msg, true); // true = isPending
|
||||
|
||||
@ -1,16 +1,26 @@
|
||||
/**
|
||||
* @fileoverview Chat History Loader - DEPRECATED
|
||||
* @module components/chatHistoryLoader
|
||||
*
|
||||
* @deprecated Используйте ChatControllerV2 вместо этого модуля.
|
||||
* Этот модуль сохранен для обратной совместимости.
|
||||
*
|
||||
* МИГРАЦИЯ:
|
||||
* - Вместо initChatHistoryLoader() используйте ChatControllerV2
|
||||
* - Вместо loadMore() используйте chatController.loadMoreHistory()
|
||||
* - Вся логика загрузки теперь в MessageLoaderV2 и ScrollManagerV2
|
||||
*
|
||||
* @see controllers/chatControllerV2.js
|
||||
* @see loaders/messageLoaderV2.js
|
||||
* @see managers/scrollManagerV2.js
|
||||
*/
|
||||
|
||||
import {
|
||||
createMessageElement,
|
||||
createDayDivider,
|
||||
formatDay,
|
||||
toTimestamp
|
||||
} from './chatMessageTemplates.js';
|
||||
|
||||
/**
|
||||
* Chat History Loader - загрузка ранних сообщений при прокрутке вверх
|
||||
*
|
||||
* РЕФАКТОРИНГ: Теперь использует MessageRenderer для создания элементов сообщений
|
||||
*/
|
||||
|
||||
const DEFAULT_CONFIG = {
|
||||
scrollSelector: '#chatScroll',
|
||||
loaderSelector: '[data-history-loader]',
|
||||
@ -128,16 +138,25 @@ export function initChatHistoryLoader(options = {}) {
|
||||
const fragment = document.createDocumentFragment();
|
||||
let prevDay = null;
|
||||
|
||||
// РЕФАКТОРИНГ: Создаем day-dividers вручную с правильным стилем
|
||||
// Используем messageRenderer.formatDay() для единообразия
|
||||
messages.forEach((msg) => {
|
||||
// Определяем день сообщения
|
||||
const ts = toTimestamp(msg);
|
||||
const day = msg.day || formatDay(ts);
|
||||
const msgDate = new Date(ts);
|
||||
const day = messageRenderer ? messageRenderer.formatDay(msgDate) : formatDay(ts);
|
||||
|
||||
// Создаем day-divider если день изменился
|
||||
if (day !== prevDay) {
|
||||
fragment.appendChild(createDayDivider(day));
|
||||
// Создаем divider с ПРАВИЛЬНЫМ стилем (как в messageRenderer)
|
||||
const dividerEl = document.createElement('div');
|
||||
dividerEl.className = 'day-divider text-center small text-muted my-3';
|
||||
dividerEl.innerHTML = `<span class="px-3 py-1 rounded-pill bg-light">${day}</span>`;
|
||||
fragment.appendChild(dividerEl);
|
||||
}
|
||||
prevDay = day;
|
||||
|
||||
// РЕФАКТОРИНГ: Используем MessageRenderer если доступен
|
||||
// Создаем элемент сообщения
|
||||
let element;
|
||||
if (messageRenderer) {
|
||||
element = messageRenderer.createMessageElement(msg, false);
|
||||
|
||||
@ -84,43 +84,12 @@ export class MessageContextMenu {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('[MessageContextMenu] Setting up MutationObserver');
|
||||
// РЕФАКТОРИНГ: MutationObserver УДАЛЕН
|
||||
// Он дублировал обработку сообщений с событием chat:message-added
|
||||
// Теперь используется ТОЛЬКО событийная модель - chat-detail-enhanced.js
|
||||
// слушает chat:message-added и вызывает attachToMessage
|
||||
|
||||
const observer = new MutationObserver((mutations) => {
|
||||
mutations.forEach((mutation) => {
|
||||
mutation.addedNodes.forEach((node) => {
|
||||
if (node.nodeType === Node.ELEMENT_NODE) {
|
||||
// Если добавлено само сообщение
|
||||
if (node.classList?.contains('msg') && node.dataset.messageId) {
|
||||
if (!node.dataset.contextMenuAttached) {
|
||||
console.log('[MessageContextMenu] New message detected:', node.dataset.messageId);
|
||||
this.attachToMessage(node);
|
||||
node.dataset.contextMenuAttached = 'true';
|
||||
}
|
||||
}
|
||||
|
||||
// Если добавлен контейнер с сообщениями
|
||||
const newMessages = node.querySelectorAll?.('.msg[data-message-id]');
|
||||
if (newMessages) {
|
||||
newMessages.forEach((msg) => {
|
||||
if (!msg.dataset.contextMenuAttached) {
|
||||
console.log('[MessageContextMenu] New message in batch:', msg.dataset.messageId);
|
||||
this.attachToMessage(msg);
|
||||
msg.dataset.contextMenuAttached = 'true';
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
observer.observe(container, {
|
||||
childList: true,
|
||||
subtree: true
|
||||
});
|
||||
|
||||
this.observer = observer;
|
||||
console.log('[MessageContextMenu] Observer setup skipped - using event-based model only');
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -116,6 +116,22 @@ export class MessageRenderer {
|
||||
return;
|
||||
}
|
||||
|
||||
// 🔥 КРИТИЧНО: Проверяем нужен ли day-divider перед новым сообщением
|
||||
const msgDate = new Date(msg.created_ts);
|
||||
const msgDay = this.formatDay(msgDate);
|
||||
|
||||
// Находим последний day-divider в контейнере
|
||||
const lastDivider = container.querySelector('.day-divider:last-of-type');
|
||||
const lastDividerText = lastDivider ? lastDivider.textContent.trim() : null;
|
||||
|
||||
// Если дня еще нет или день изменился - создаем divider
|
||||
if (!lastDividerText || lastDividerText !== msgDay) {
|
||||
const dividerEl = document.createElement('div');
|
||||
dividerEl.className = 'day-divider text-center small text-muted my-3';
|
||||
dividerEl.innerHTML = `<span class="px-3 py-1 rounded-pill bg-light">${msgDay}</span>`;
|
||||
container.appendChild(dividerEl);
|
||||
}
|
||||
|
||||
const isOwn = msg.author_id === this.currentUserId;
|
||||
const messageHtml = this.buildMessageHtml(msg, isOwn);
|
||||
|
||||
|
||||
@ -10,7 +10,6 @@
|
||||
*/
|
||||
|
||||
import { getChatAvatar } from './chatAvatarMap.js';
|
||||
import { formatDay, createDayDivider, createMessageElement } from './chatMessageTemplates.js';
|
||||
|
||||
/**
|
||||
* Инициализирует WebSocket соединение для пользователя
|
||||
@ -255,45 +254,32 @@ export function initUserWebSocket(options = {}) {
|
||||
loader.remove();
|
||||
}
|
||||
|
||||
// Отправляем событие для других модулей
|
||||
window.dispatchEvent(new CustomEvent('chat:initial-messages-loaded', {
|
||||
detail: { count: messages ? messages.length : 0 }
|
||||
console.log('[UserWS] Dispatching ws:initial-messages with %d messages', messages?.length || 0);
|
||||
|
||||
// НОВАЯ АРХИТЕКТУРА: Dispatch события вместо прямого вызова renderer
|
||||
// ChatController подпишется на это событие и обработает через MessageLoader
|
||||
window.dispatchEvent(new CustomEvent('ws:initial-messages', {
|
||||
detail: {
|
||||
messages: messages || [],
|
||||
chatId: state.activeChatId
|
||||
}
|
||||
}));
|
||||
|
||||
// Если сообщений нет, завершаем обработку
|
||||
if (!messages || !messages.length) {
|
||||
console.log('[UserWS] No initial messages to render');
|
||||
return;
|
||||
}
|
||||
|
||||
// Используем MessageRenderer для добавления сообщений (с проверкой дубликатов)
|
||||
if (!options.messageRenderer) {
|
||||
console.error('[UserWebSocket] MessageRenderer not configured for initial_messages!');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('[UserWS] Rendering %d initial messages via MessageRenderer', messages.length);
|
||||
|
||||
// ОПТИМИЗАЦИЯ: Скрываем контейнер перед рендерингом для предотвращения reflow
|
||||
const wasVisible = state.scrollEl.style.visibility;
|
||||
state.scrollEl.style.visibility = 'hidden';
|
||||
|
||||
// Используем батчевый renderMessages вместо forEach для лучшей производительности
|
||||
options.messageRenderer.renderMessages(messages);
|
||||
|
||||
// Используем requestAnimationFrame для гарантии что DOM обновлен перед прокруткой
|
||||
requestAnimationFrame(() => {
|
||||
// Устанавливаем scroll в конец ДО показа контейнера
|
||||
state.scrollEl.scrollTop = state.scrollEl.scrollHeight;
|
||||
// УСТАРЕВШЕЕ: Старая логика с messageRenderer остается для backward compatibility
|
||||
// Удалится после полной миграции на ChatController
|
||||
if (options.messageRenderer && messages?.length) {
|
||||
console.log('[UserWS] [DEPRECATED] Using old messageRenderer for backward compatibility');
|
||||
options.messageRenderer.renderMessages(messages);
|
||||
|
||||
// Показываем контейнер - пользователь сразу видит конец диалога
|
||||
state.scrollEl.style.visibility = wasVisible || '';
|
||||
});
|
||||
requestAnimationFrame(() => {
|
||||
state.scrollEl.scrollTop = state.scrollEl.scrollHeight;
|
||||
});
|
||||
}
|
||||
|
||||
// Инициализируем голосования после загрузки сообщений
|
||||
if (window.chatPoll) {
|
||||
if (window.chatPoll && messages?.length) {
|
||||
console.log('[UserWS] Initializing polls after initial messages load');
|
||||
window.chatPoll.initializeExistingPolls();
|
||||
setTimeout(() => window.chatPoll.initializeExistingPolls(), 100);
|
||||
}
|
||||
}
|
||||
|
||||
@ -303,44 +289,39 @@ export function initUserWebSocket(options = {}) {
|
||||
const { message } = data;
|
||||
if (!message) return;
|
||||
|
||||
// Рендерим сообщение через MessageRenderer
|
||||
if (!options.messageRenderer) {
|
||||
console.error('[UserWebSocket] MessageRenderer not configured!');
|
||||
return;
|
||||
}
|
||||
|
||||
options.messageRenderer.renderMessage(message);
|
||||
console.log('[UserWS] Dispatching ws:new-message for message id=%s', message.id);
|
||||
|
||||
// Автоскролл ТОЛЬКО если это наше сообщение ИЛИ мы уже внизу
|
||||
// Предотвращаем принудительный скролл при получении чужих сообщений
|
||||
const isAtBottom = markReadApi?.atBottom?.() ?? false;
|
||||
const isOwnMessage = message.author_id === userId;
|
||||
|
||||
if (isOwnMessage || isAtBottom) {
|
||||
// Используем requestAnimationFrame для плавности
|
||||
requestAnimationFrame(() => {
|
||||
scrollToBottom();
|
||||
});
|
||||
}
|
||||
|
||||
// Отмечаем как прочитанное
|
||||
const msgEl = state.scrollEl.querySelector(`[data-message-id="${message.id}"]`);
|
||||
if (msgEl && markReadApi?.observeLastForeign) {
|
||||
markReadApi.observeLastForeign(msgEl);
|
||||
}
|
||||
|
||||
// Инициализируем реакции для нового сообщения
|
||||
window.dispatchEvent(new CustomEvent('chat:message-added', {
|
||||
detail: { messageElement: msgEl, messageId: message.id }
|
||||
// НОВАЯ АРХИТЕКТУРА: Dispatch события для ChatController
|
||||
window.dispatchEvent(new CustomEvent('ws:new-message', {
|
||||
detail: {
|
||||
message,
|
||||
chatId: state.activeChatId,
|
||||
isOwnMessage: message.author_id === userId
|
||||
}
|
||||
}));
|
||||
|
||||
// УСТАРЕВШЕЕ: Старая логика остается для backward compatibility
|
||||
if (options.messageRenderer) {
|
||||
console.log('[UserWS] [DEPRECATED] Using old messageRenderer for backward compatibility');
|
||||
options.messageRenderer.renderMessage(message);
|
||||
|
||||
const isAtBottom = markReadApi?.atBottom?.() ?? false;
|
||||
const isOwnMessage = message.author_id === userId;
|
||||
|
||||
if (isOwnMessage || isAtBottom) {
|
||||
requestAnimationFrame(() => scrollToBottom());
|
||||
}
|
||||
|
||||
const msgEl = state.scrollEl.querySelector(`[data-message-id="${message.id}"]`);
|
||||
if (msgEl && markReadApi?.observeLastForeign) {
|
||||
markReadApi.observeLastForeign(msgEl);
|
||||
}
|
||||
}
|
||||
|
||||
// Если в сообщении есть голосование, инициализируем его
|
||||
if (message.poll && window.chatPoll) {
|
||||
console.log('[UserWS] New message with poll detected, poll_id=%s', message.poll.id);
|
||||
// Небольшая задержка чтобы DOM успел обновиться
|
||||
setTimeout(() => {
|
||||
window.chatPoll.refreshPoll(message.poll.id);
|
||||
}, 100);
|
||||
setTimeout(() => window.chatPoll.refreshPoll(message.poll.id), 100);
|
||||
}
|
||||
}
|
||||
|
||||
@ -348,11 +329,17 @@ export function initUserWebSocket(options = {}) {
|
||||
const { message } = data;
|
||||
if (!message) return;
|
||||
|
||||
// УСТАРЕЛО: Старая логика частичного обновления
|
||||
// Теперь используем полную перерисовку через событие
|
||||
console.log('[UserWebSocket] message_updated received, dispatching chat:message-edited');
|
||||
console.log('[UserWS] Dispatching ws:message-edited for message id=%s', message.id);
|
||||
|
||||
// Диспатчим событие для полной перерисовки
|
||||
// НОВАЯ АРХИТЕКТУРА: ChatController обработает через MessageLoader
|
||||
window.dispatchEvent(new CustomEvent('ws:message-edited', {
|
||||
detail: {
|
||||
message,
|
||||
chatId: data.chat_id || state.activeChatId
|
||||
}
|
||||
}));
|
||||
|
||||
// Backward compatibility: старое событие
|
||||
window.dispatchEvent(new CustomEvent('chat:message-edited', {
|
||||
detail: {
|
||||
payload: message,
|
||||
@ -362,39 +349,32 @@ export function initUserWebSocket(options = {}) {
|
||||
}
|
||||
|
||||
function handleMessageDeleted(data) {
|
||||
console.log('[UserWebSocket] handleMessageDeleted called:', data);
|
||||
console.log('[UserWS] handleMessageDeleted called:', data);
|
||||
|
||||
const { message_id } = data;
|
||||
if (!message_id) {
|
||||
console.warn('[UserWebSocket] No message_id in delete event');
|
||||
console.warn('[UserWS] No message_id in delete event');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('[UserWebSocket] Looking for message element with id:', message_id);
|
||||
console.log('[UserWebSocket] Scroll element:', state.scrollEl?.id);
|
||||
console.log('[UserWS] Dispatching ws:message-removed for message id=%s', message_id);
|
||||
|
||||
// НОВАЯ АРХИТЕКТУРА: ChatController обработает через MessageLoader
|
||||
window.dispatchEvent(new CustomEvent('ws:message-removed', {
|
||||
detail: {
|
||||
messageId: message_id,
|
||||
chatId: data.chat_id || state.activeChatId
|
||||
}
|
||||
}));
|
||||
|
||||
// УСТАРЕВШЕЕ: Старая логика для backward compatibility
|
||||
const msgEl = state.scrollEl?.querySelector(`[data-message-id="${message_id}"]`);
|
||||
|
||||
if (msgEl) {
|
||||
console.log('[UserWebSocket] Message element found:', msgEl);
|
||||
console.log('[UserWebSocket] Message element classes:', msgEl.className);
|
||||
console.log('[UserWebSocket] Removing message from DOM...');
|
||||
|
||||
// Плавная анимация удаления
|
||||
console.log('[UserWS] [DEPRECATED] Using old direct DOM removal');
|
||||
msgEl.style.transition = 'opacity 0.3s ease-out, transform 0.3s ease-out';
|
||||
msgEl.style.opacity = '0';
|
||||
msgEl.style.transform = 'scale(0.8)';
|
||||
|
||||
setTimeout(() => {
|
||||
msgEl.remove();
|
||||
console.log('[UserWebSocket] Message removed successfully');
|
||||
}, 300);
|
||||
} else {
|
||||
console.warn('[UserWebSocket] Message element NOT found for id:', message_id);
|
||||
console.log('[UserWebSocket] Available messages:',
|
||||
Array.from(state.scrollEl?.querySelectorAll('[data-message-id]') || [])
|
||||
.map(el => el.dataset.messageId)
|
||||
);
|
||||
setTimeout(() => msgEl.remove(), 300);
|
||||
}
|
||||
}
|
||||
|
||||
@ -436,13 +416,28 @@ export function initUserWebSocket(options = {}) {
|
||||
function handleReactionAdded(data) {
|
||||
const { message_id, emoji, user_id, user_name, reactions_summary } = data;
|
||||
|
||||
console.log('[UserWS] Dispatching ws:reaction-added for message id=%s', message_id);
|
||||
|
||||
// НОВАЯ АРХИТЕКТУРА: Событие для ChatController
|
||||
window.dispatchEvent(new CustomEvent('ws:reaction-added', {
|
||||
detail: {
|
||||
messageId: message_id,
|
||||
emoji,
|
||||
userId: user_id,
|
||||
userName: user_name,
|
||||
reactionsSummary: reactions_summary,
|
||||
chatId: state.activeChatId
|
||||
}
|
||||
}));
|
||||
|
||||
// Backward compatibility: старое событие
|
||||
window.dispatchEvent(new CustomEvent('chat:reaction-added', {
|
||||
detail: {
|
||||
messageId: message_id,
|
||||
emoji,
|
||||
userId: user_id,
|
||||
userName: user_name,
|
||||
reactions_summary: reactions_summary // ← ДОБАВЛЕНО
|
||||
reactions_summary: reactions_summary
|
||||
}
|
||||
}));
|
||||
}
|
||||
@ -450,6 +445,20 @@ export function initUserWebSocket(options = {}) {
|
||||
function handleReactionRemoved(data) {
|
||||
const { message_id, emoji, user_id, reactions_summary } = data;
|
||||
|
||||
console.log('[UserWS] Dispatching ws:reaction-removed for message id=%s', message_id);
|
||||
|
||||
// НОВАЯ АРХИТЕКТУРА: Событие для ChatController
|
||||
window.dispatchEvent(new CustomEvent('ws:reaction-removed', {
|
||||
detail: {
|
||||
messageId: message_id,
|
||||
emoji,
|
||||
userId: user_id,
|
||||
reactionsSummary: reactions_summary,
|
||||
chatId: state.activeChatId
|
||||
}
|
||||
}));
|
||||
|
||||
// Backward compatibility: старое событие
|
||||
window.dispatchEvent(new CustomEvent('chat:reaction-removed', {
|
||||
detail: {
|
||||
messageId: message_id,
|
||||
@ -652,5 +661,11 @@ export function initUserWebSocket(options = {}) {
|
||||
isConnected: () => ws.readyState === WebSocket.OPEN
|
||||
};
|
||||
|
||||
// Сохраняем глобально для backward compatibility
|
||||
window.userWebSocket = api;
|
||||
|
||||
return api;
|
||||
}
|
||||
|
||||
// Экспортируем также прямой API для использования в модулях
|
||||
export const getUserWebSocket = () => window.userWebSocket;
|
||||
|
||||
190
backend/static/js/config/chatConfig.js
Normal file
190
backend/static/js/config/chatConfig.js
Normal file
@ -0,0 +1,190 @@
|
||||
/**
|
||||
* @fileoverview Централизованная конфигурация чат-модуля
|
||||
* @module config/chatConfig
|
||||
*/
|
||||
|
||||
/**
|
||||
* Конфигурация загрузчика сообщений
|
||||
*/
|
||||
export const LOADER_CONFIG = {
|
||||
/** Количество сообщений для начальной загрузки */
|
||||
INITIAL_LIMIT: 30,
|
||||
|
||||
/** Количество сообщений для загрузки истории */
|
||||
HISTORY_LIMIT: 20,
|
||||
|
||||
/** Максимальное количество попыток при ошибке */
|
||||
MAX_RETRIES: 3,
|
||||
|
||||
/** Базовая задержка между попытками (мс) */
|
||||
RETRY_DELAY_BASE: 1000,
|
||||
|
||||
/** Таймаут запроса (мс) */
|
||||
REQUEST_TIMEOUT: 15000,
|
||||
|
||||
/** Дебаунс для загрузки истории (мс) */
|
||||
HISTORY_DEBOUNCE: 150
|
||||
};
|
||||
|
||||
/**
|
||||
* Конфигурация скролла
|
||||
*/
|
||||
export const SCROLL_CONFIG = {
|
||||
/** Порог в px для определения "пользователь внизу" */
|
||||
BOTTOM_THRESHOLD: 100,
|
||||
|
||||
/** Порог в px для триггера загрузки истории */
|
||||
HISTORY_TRIGGER_THRESHOLD: 140,
|
||||
|
||||
/** Отступ для IntersectionObserver (px) */
|
||||
OBSERVER_ROOT_MARGIN: '100px 0px 0px 0px',
|
||||
|
||||
/** Порог видимости для IntersectionObserver (0-1) */
|
||||
OBSERVER_THRESHOLD: 0.1,
|
||||
|
||||
/** Задержка перед восстановлением позиции скролла (мс) */
|
||||
SCROLL_RESTORE_DELAY: 50
|
||||
};
|
||||
|
||||
/**
|
||||
* Конфигурация рендеринга
|
||||
*/
|
||||
export const RENDER_CONFIG = {
|
||||
/** Использовать visibility:hidden при рендеринге */
|
||||
USE_VISIBILITY_HIDING: true,
|
||||
|
||||
/** Задержка перед скроллом после рендеринга (мс) */
|
||||
POST_RENDER_DELAY: 50,
|
||||
|
||||
/** Максимальное количество сообщений в DOM */
|
||||
MAX_DOM_MESSAGES: 500,
|
||||
|
||||
/** Количество сообщений для виртуализации */
|
||||
VIRTUALIZATION_BUFFER: 50
|
||||
};
|
||||
|
||||
/**
|
||||
* Конфигурация WebSocket
|
||||
*/
|
||||
export const WS_CONFIG = {
|
||||
/** Таймаут ожидания подключения (мс) */
|
||||
CONNECTION_TIMEOUT: 10000,
|
||||
|
||||
/** Интервал ping (мс) */
|
||||
PING_INTERVAL: 30000,
|
||||
|
||||
/** Максимальное время ожидания pong (мс) */
|
||||
PONG_TIMEOUT: 5000
|
||||
};
|
||||
|
||||
/**
|
||||
* Типы событий Store
|
||||
* @enum {string}
|
||||
*/
|
||||
export const STORE_EVENTS = {
|
||||
MESSAGE_ADDED: 'message_added',
|
||||
MESSAGE_UPDATED: 'message_updated',
|
||||
MESSAGE_REMOVED: 'message_removed',
|
||||
MESSAGES_LOADED: 'messages_loaded',
|
||||
CHAT_CLEARED: 'chat_cleared',
|
||||
OPTIMISTIC_CONFIRMED: 'optimistic_confirmed',
|
||||
OPTIMISTIC_FAILED: 'optimistic_failed'
|
||||
};
|
||||
|
||||
/**
|
||||
* Типы событий чата (для window events)
|
||||
* @enum {string}
|
||||
*/
|
||||
export const CHAT_EVENTS = {
|
||||
INITIALIZED: 'chat:initialized',
|
||||
MESSAGE_ADDED: 'chat:message-added',
|
||||
MESSAGE_UPDATED: 'chat:message-updated',
|
||||
MESSAGE_REMOVED: 'chat:message-removed',
|
||||
HISTORY_LOADED: 'chat:history-loaded',
|
||||
SCROLL_TO_BOTTOM: 'chat:scroll-to-bottom',
|
||||
ERROR: 'chat:error'
|
||||
};
|
||||
|
||||
/**
|
||||
* Типы WebSocket событий
|
||||
* @enum {string}
|
||||
*/
|
||||
export const WS_EVENTS = {
|
||||
NEW_MESSAGE: 'ws:new-message',
|
||||
MESSAGE_EDITED: 'ws:message-edited',
|
||||
MESSAGE_REMOVED: 'ws:message-removed',
|
||||
REACTION_ADDED: 'ws:reaction-added',
|
||||
REACTION_REMOVED: 'ws:reaction-removed'
|
||||
};
|
||||
|
||||
/**
|
||||
* Статусы сообщения
|
||||
* @enum {string}
|
||||
*/
|
||||
export const MESSAGE_STATUS = {
|
||||
SENDING: 'sending',
|
||||
SENT: 'sent',
|
||||
FAILED: 'failed',
|
||||
READ: 'read'
|
||||
};
|
||||
|
||||
/**
|
||||
* API endpoints (шаблоны)
|
||||
*/
|
||||
export const API_ENDPOINTS = {
|
||||
/** Получить сообщения чата */
|
||||
MESSAGES: (chatId) => `/api/v1/communications/chats/${chatId}/messages/`,
|
||||
|
||||
/** Получить сообщения вокруг конкретного ID */
|
||||
MESSAGES_AROUND: (chatId) => `/api/v1/communications/chats/${chatId}/messages/around/`,
|
||||
|
||||
/** Отметить сообщения как прочитанные */
|
||||
MARK_READ: (chatId) => `/api/v1/communications/chats/${chatId}/mark-read/`,
|
||||
|
||||
/** Отправить сообщение */
|
||||
SEND_MESSAGE: (chatId) => `/api/v1/communications/chats/${chatId}/messages/`,
|
||||
|
||||
/** Загрузить файл */
|
||||
UPLOAD_FILE: (chatId) => `/api/v1/communications/chats/${chatId}/upload/`
|
||||
};
|
||||
|
||||
/**
|
||||
* Генерирует URL с query параметрами
|
||||
* @param {string} baseUrl - Базовый URL
|
||||
* @param {Object} params - Параметры запроса
|
||||
* @returns {string} URL с параметрами
|
||||
*/
|
||||
export function buildUrl(baseUrl, params = {}) {
|
||||
const url = new URL(baseUrl, window.location.origin);
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
if (value !== null && value !== undefined) {
|
||||
url.searchParams.set(key, String(value));
|
||||
}
|
||||
});
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Создает заголовки для AJAX запросов
|
||||
* @returns {Object} Заголовки
|
||||
*/
|
||||
export function getRequestHeaders() {
|
||||
return {
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
'Content-Type': 'application/json'
|
||||
};
|
||||
}
|
||||
|
||||
export default {
|
||||
LOADER_CONFIG,
|
||||
SCROLL_CONFIG,
|
||||
RENDER_CONFIG,
|
||||
WS_CONFIG,
|
||||
STORE_EVENTS,
|
||||
CHAT_EVENTS,
|
||||
WS_EVENTS,
|
||||
MESSAGE_STATUS,
|
||||
API_ENDPOINTS,
|
||||
buildUrl,
|
||||
getRequestHeaders
|
||||
};
|
||||
562
backend/static/js/controllers/chatController.js
Normal file
562
backend/static/js/controllers/chatController.js
Normal file
@ -0,0 +1,562 @@
|
||||
/**
|
||||
* @fileoverview ChatController - главный координатор чата
|
||||
* @module controllers/chatController
|
||||
*
|
||||
* Единая точка входа для работы с чатом.
|
||||
* Координирует все компоненты: Store, Loader, Renderer, ScrollManager
|
||||
*/
|
||||
|
||||
import { MessageStore } from '../stores/messageStore.js';
|
||||
import { MessageLoader } from '../loaders/messageLoader.js';
|
||||
import { MessageRendererV2 } from '../renderers/messageRendererV2.js';
|
||||
import { ScrollManager } from '../managers/scrollManager.js';
|
||||
|
||||
/**
|
||||
* ChatController - координатор чата
|
||||
*/
|
||||
export class ChatController {
|
||||
/**
|
||||
* @param {Object} options - Опции конфигурации
|
||||
* @param {number} options.chatId - ID чата
|
||||
* @param {HTMLElement} options.scrollElement - Контейнер для скролла
|
||||
* @param {string} options.containerId - ID контейнера сообщений
|
||||
* @param {number} options.currentUserId - ID текущего пользователя
|
||||
* @param {Object} options.wsConnection - WebSocket соединение
|
||||
* @param {string} [options.profileUrl] - URL профиля
|
||||
* @param {string} [options.detailUrlTemplate] - URL детальной информации
|
||||
* @param {number} [options.lastReadTimestamp] - Timestamp последнего прочитанного сообщения
|
||||
*/
|
||||
constructor(options) {
|
||||
if (!options.chatId) {
|
||||
throw new Error('[ChatController] chatId is required');
|
||||
}
|
||||
if (!options.scrollElement) {
|
||||
throw new Error('[ChatController] scrollElement is required');
|
||||
}
|
||||
if (!options.currentUserId) {
|
||||
throw new Error('[ChatController] currentUserId is required');
|
||||
}
|
||||
|
||||
this.chatId = options.chatId;
|
||||
this.currentUserId = options.currentUserId;
|
||||
this.scrollElement = options.scrollElement;
|
||||
this.scrollEl = options.scrollElement; // alias
|
||||
this.containerId = options.containerId || 'chatScroll';
|
||||
this.lastReadTimestamp = options.lastReadTimestamp || null;
|
||||
|
||||
console.log('[ChatController] Initializing for chat:', this.chatId);
|
||||
|
||||
// Создаем компоненты
|
||||
this.store = new MessageStore({ currentUserId: this.currentUserId });
|
||||
|
||||
this.loader = new MessageLoader({
|
||||
store: this.store,
|
||||
wsConnection: options.wsConnection,
|
||||
currentUserId: this.currentUserId
|
||||
});
|
||||
|
||||
this.renderer = new MessageRendererV2({
|
||||
store: this.store,
|
||||
containerId: this.containerId,
|
||||
currentUserId: this.currentUserId,
|
||||
profileUrl: options.profileUrl,
|
||||
detailUrlTemplate: options.detailUrlTemplate
|
||||
});
|
||||
|
||||
this.scrollManager = new ScrollManager({
|
||||
scrollElement: this.scrollElement,
|
||||
messageLoader: this.loader,
|
||||
messageRenderer: this.renderer,
|
||||
messageStore: this.store,
|
||||
chatId: this.chatId
|
||||
});
|
||||
|
||||
// Флаг инициализации
|
||||
this.initialized = false;
|
||||
this.isInitializing = false;
|
||||
|
||||
// Подписываемся на изменения Store
|
||||
this._subscribeToStore();
|
||||
|
||||
// Подписываемся на WebSocket (если есть)
|
||||
if (options.wsConnection) {
|
||||
this._subscribeToWebSocket(options.wsConnection);
|
||||
}
|
||||
|
||||
console.log('[ChatController] Created');
|
||||
}
|
||||
|
||||
// ==================== Инициализация ====================
|
||||
|
||||
/**
|
||||
* Инициализирует чат (загружает сообщения, рендерит, скроллит)
|
||||
* @param {Object} options - Опции
|
||||
* @param {boolean} [options.scrollToBottom=true] - Прокрутить вниз после загрузки
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async init(options = {}) {
|
||||
if (this.initialized) {
|
||||
console.warn('[ChatController] Already initialized');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
console.log('[ChatController] ========================================');
|
||||
console.log('[ChatController] Initializing chat:', this.chatId);
|
||||
console.log('[ChatController] lastReadTimestamp:', this.lastReadTimestamp);
|
||||
console.log('[ChatController] lastReadTimestamp type:', typeof this.lastReadTimestamp);
|
||||
console.log('[ChatController] ========================================');
|
||||
|
||||
// Устанавливаем флаг что идет начальная загрузка
|
||||
this.isInitializing = true;
|
||||
|
||||
// 1. Загружаем начальные сообщения ВОКРУГ last_read
|
||||
console.log('[ChatController] Step 1: Loading messages around last read...');
|
||||
const loadResult = await this.loader.loadInitialMessages(this.chatId, {
|
||||
lastReadMessageId: this.lastReadTimestamp // Используем как ID (нужно переименовать)
|
||||
});
|
||||
|
||||
console.log('[ChatController] ========================================');
|
||||
console.log('[ChatController] Load result:', loadResult);
|
||||
console.log('[ChatController] ========================================');
|
||||
|
||||
// 2. Скрываем контейнер чтобы избежать видимых прыжков
|
||||
console.log('[ChatController] Step 2: Hiding container for smooth render...');
|
||||
this.scrollEl.style.visibility = 'hidden';
|
||||
|
||||
try {
|
||||
// 3. Рендерим (пользователь не видит процесс)
|
||||
console.log('[ChatController] Step 3: Rendering messages...');
|
||||
await this.renderer.render(this.chatId);
|
||||
|
||||
// 4. ВАЖНО: Показываем контейнер ДО скролла (scrollTop не работает на hidden)
|
||||
console.log('[ChatController] Step 4: Showing container...');
|
||||
this.scrollEl.style.visibility = '';
|
||||
|
||||
// 5. Прокручиваем к нужной позиции ПОСЛЕ показа контейнера
|
||||
console.log('[ChatController] Step 5: Scrolling to position...');
|
||||
console.log('[ChatController] Anchor ID from loadResult:', loadResult.anchorId);
|
||||
|
||||
if (loadResult.anchorId) {
|
||||
// Есть anchor - пытаемся скроллить к нему
|
||||
const scrolled = await this._scrollToMessage(loadResult.anchorId);
|
||||
console.log('[ChatController] Scrolled to anchor message:', loadResult.anchorId, '→', scrolled);
|
||||
|
||||
if (!scrolled) {
|
||||
// Не нашли anchor - оставляем прокрутку как есть (не скроллим автоматически)
|
||||
console.log('[ChatController] Anchor not found, keeping scroll position');
|
||||
}
|
||||
} else {
|
||||
// Нет anchor (нет lastReadId) - оставляем прокрутку как есть (НЕ скроллим автоматически вниз)
|
||||
console.log('[ChatController] No anchor, keeping scroll position');
|
||||
}
|
||||
} catch (scrollError) {
|
||||
console.error('[ChatController] ❌ Error during init:', scrollError);
|
||||
// В случае ошибки всё равно показываем контейнер
|
||||
this.scrollEl.style.visibility = '';
|
||||
throw scrollError;
|
||||
}
|
||||
|
||||
// 6. Инициализируем ScrollManager ПОСЛЕ скролла
|
||||
console.log('[ChatController] Step 6: Initializing ScrollManager...');
|
||||
await this.scrollManager.init();
|
||||
|
||||
this.initialized = true;
|
||||
this.isInitializing = false; // Сбрасываем флаг
|
||||
console.log('[ChatController] ✅ Initialization complete');
|
||||
|
||||
// Dispatch события для других компонентов
|
||||
this._dispatchEvent('chat:initialized', { chatId: this.chatId });
|
||||
|
||||
} catch (error) {
|
||||
console.error('[ChatController] ❌ Initialization failed:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Прокручивает к конкретному сообщению
|
||||
* @private
|
||||
* @param {number} messageId - ID сообщения
|
||||
* @param {number} [offset=100] - Отступ сверху в пикселях
|
||||
* @returns {Promise<boolean>} true если нашли и прокрутили
|
||||
*/
|
||||
async _scrollToMessage(messageId, offset = 100) {
|
||||
console.log('[ChatController] ========================================');
|
||||
console.log('[ChatController] _scrollToMessage called');
|
||||
console.log('[ChatController] messageId:', messageId);
|
||||
console.log('[ChatController] offset:', offset);
|
||||
|
||||
if (!messageId) {
|
||||
console.log('[ChatController] No messageId provided');
|
||||
console.log('[ChatController] ========================================');
|
||||
return false;
|
||||
}
|
||||
|
||||
// Даём время на завершение DOM updates и browser reflow
|
||||
await new Promise(resolve => setTimeout(resolve, 150));
|
||||
|
||||
// Ищем элемент в DOM
|
||||
const messageEl = this.scrollEl.querySelector(`[data-message-id="${messageId}"]`);
|
||||
console.log('[ChatController] Found element:', !!messageEl);
|
||||
|
||||
if (messageEl) {
|
||||
console.log('[ChatController] Element classes:', messageEl.className);
|
||||
|
||||
// Используем scrollIntoView - надежнее чем ручной scrollTop
|
||||
messageEl.scrollIntoView({
|
||||
behavior: 'instant', // без анимации
|
||||
block: 'center', // в центр viewport
|
||||
inline: 'nearest'
|
||||
});
|
||||
|
||||
console.log('[ChatController] ✅ Scrolled to message:', messageId);
|
||||
console.log('[ChatController] ========================================');
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!messageEl) {
|
||||
console.warn('[ChatController] Message element not found in DOM:', messageId);
|
||||
console.log('[ChatController] Available message IDs:',
|
||||
Array.from(this.scrollEl.querySelectorAll('[data-message-id]'))
|
||||
.map(el => el.dataset.messageId).slice(0, 10)
|
||||
);
|
||||
console.log('[ChatController] ========================================');
|
||||
return false;
|
||||
}
|
||||
|
||||
// Прокручиваем к элементу с отступом сверху
|
||||
const offsetTop = messageEl.offsetTop - offset;
|
||||
const scrollBefore = this.scrollEl.scrollTop;
|
||||
this.scrollEl.scrollTop = Math.max(0, offsetTop);
|
||||
const scrollAfter = this.scrollEl.scrollTop;
|
||||
|
||||
console.log('[ChatController] Scroll details:');
|
||||
console.log('[ChatController] Element offsetTop:', messageEl.offsetTop);
|
||||
console.log('[ChatController] Target scrollTop:', offsetTop);
|
||||
console.log('[ChatController] Scroll before:', scrollBefore);
|
||||
console.log('[ChatController] Scroll after:', scrollAfter);
|
||||
console.log('[ChatController] ✅ Scrolled to message', messageId);
|
||||
console.log('[ChatController] ========================================');
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Уничтожает контроллер и освобождает ресурсы
|
||||
*/
|
||||
destroy() {
|
||||
console.log('[ChatController] Destroying...');
|
||||
|
||||
// Останавливаем ScrollManager
|
||||
if (this.scrollManager) {
|
||||
this.scrollManager.destroy();
|
||||
}
|
||||
|
||||
// Очищаем Store
|
||||
if (this.store) {
|
||||
this.store.clearChat(this.chatId);
|
||||
}
|
||||
|
||||
// Очищаем рендерер
|
||||
if (this.renderer) {
|
||||
this.renderer.clear();
|
||||
}
|
||||
|
||||
this.initialized = false;
|
||||
console.log('[ChatController] Destroyed');
|
||||
}
|
||||
|
||||
// ==================== Подписки ====================
|
||||
|
||||
/**
|
||||
* Подписывается на изменения Store
|
||||
* @private
|
||||
*/
|
||||
_subscribeToStore() {
|
||||
this.store.subscribe((event, data) => {
|
||||
this._handleStoreUpdate(event, data);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Обрабатывает обновления из Store
|
||||
* @private
|
||||
*/
|
||||
_handleStoreUpdate(event, data) {
|
||||
console.log('[ChatController] Store update:', event, data);
|
||||
|
||||
switch (event) {
|
||||
case 'message_added':
|
||||
this._handleMessageAdded(data);
|
||||
break;
|
||||
|
||||
case 'message_updated':
|
||||
this._handleMessageUpdated(data);
|
||||
break;
|
||||
|
||||
case 'message_removed':
|
||||
this._handleMessageRemoved(data);
|
||||
break;
|
||||
|
||||
case 'messages_loaded':
|
||||
// Batch загрузка - ничего не делаем, render() вызовется вручную
|
||||
break;
|
||||
|
||||
default:
|
||||
console.log('[ChatController] Unhandled store event:', event);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Обрабатывает добавление сообщения
|
||||
* @private
|
||||
*/
|
||||
_handleMessageAdded(data) {
|
||||
const { message, optimistic } = data;
|
||||
|
||||
// ВАЖНО: Игнорируем события во время начальной загрузки
|
||||
// Начальные сообщения рендерятся через render(), а не через appendMessage
|
||||
if (this.isInitializing) {
|
||||
console.log('[ChatController] Ignoring message_added during initialization:', message.id);
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.chat_id !== this.chatId) {
|
||||
// Сообщение для другого чата
|
||||
return;
|
||||
}
|
||||
|
||||
// Incremental render - добавляем только новое сообщение
|
||||
const wasEmpty = this.renderer.renderedMessages.size === 0;
|
||||
this.renderer.appendMessage(message, this.chatId);
|
||||
|
||||
// Если это первое сообщение, настраиваем Observer
|
||||
if (wasEmpty) {
|
||||
this.scrollManager.setupIntersectionObserver();
|
||||
}
|
||||
|
||||
// Автоскролл ТОЛЬКО если это наше сообщение (отправили мы)
|
||||
// Убрали автоскролл при получении чужих сообщений даже если пользователь внизу
|
||||
const shouldScroll = message.author_id === this.currentUserId;
|
||||
|
||||
if (shouldScroll && !optimistic) {
|
||||
// Плавная прокрутка к нашему новому сообщению
|
||||
requestAnimationFrame(() => {
|
||||
this.scrollEl.scrollTop = this.scrollEl.scrollHeight;
|
||||
});
|
||||
}
|
||||
|
||||
// Dispatch события для других компонентов (reactions, context menu, etc.)
|
||||
this._dispatchEvent('chat:message-added', {
|
||||
messageId: message.id,
|
||||
chatId: this.chatId
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Обрабатывает обновление сообщения
|
||||
* @private
|
||||
*/
|
||||
_handleMessageUpdated(data) {
|
||||
const { messageId, updates } = data;
|
||||
|
||||
// Патчим DOM
|
||||
this.renderer.updateMessage(messageId, updates);
|
||||
|
||||
// Dispatch события
|
||||
this._dispatchEvent('chat:message-updated', {
|
||||
messageId,
|
||||
updates,
|
||||
chatId: this.chatId
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Обрабатывает удаление сообщения
|
||||
* @private
|
||||
*/
|
||||
_handleMessageRemoved(data) {
|
||||
const { messageId } = data;
|
||||
|
||||
// Удаляем из DOM
|
||||
this.renderer.removeMessage(messageId);
|
||||
|
||||
// Dispatch события
|
||||
this._dispatchEvent('chat:message-removed', {
|
||||
messageId,
|
||||
chatId: this.chatId
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Подписывается на события WebSocket
|
||||
* @private
|
||||
*/
|
||||
_subscribeToWebSocket(ws) {
|
||||
// Используем глобальные события вместо прямой подписки
|
||||
// Это позволяет userWebSocket оставаться независимым
|
||||
|
||||
// ВАЖНО: ws:initial-messages НЕ обрабатываем!
|
||||
// Initial messages загружаются через HTTP в init()
|
||||
// Если обрабатывать WS initial - получим двойной render и прыжки
|
||||
|
||||
// Новое сообщение
|
||||
window.addEventListener('ws:new-message', (event) => {
|
||||
const { message, chatId } = event.detail;
|
||||
if (chatId === this.chatId && message) {
|
||||
console.log('[ChatController] WS: new-message, id=%s', message.id);
|
||||
this.loader.handleNewMessage(message);
|
||||
}
|
||||
});
|
||||
|
||||
// Редактирование сообщения
|
||||
window.addEventListener('ws:message-edited', (event) => {
|
||||
const { message, chatId } = event.detail;
|
||||
if (chatId === this.chatId && message) {
|
||||
console.log('[ChatController] WS: message-edited, id=%s', message.id);
|
||||
this.loader.handleMessageEdited({ message });
|
||||
}
|
||||
});
|
||||
|
||||
// Удаление сообщения
|
||||
window.addEventListener('ws:message-removed', (event) => {
|
||||
const { messageId, chatId } = event.detail;
|
||||
if (chatId === this.chatId && messageId) {
|
||||
console.log('[ChatController] WS: message-removed, id=%s', messageId);
|
||||
this.loader.handleMessageRemoved({ message_id: messageId });
|
||||
}
|
||||
});
|
||||
|
||||
// Добавление реакции
|
||||
window.addEventListener('ws:reaction-added', (event) => {
|
||||
const { messageId, reactionsSummary, chatId } = event.detail;
|
||||
if (chatId === this.chatId && messageId) {
|
||||
console.log('[ChatController] WS: reaction-added, msg=%s', messageId);
|
||||
this.loader.handleReactionAdded({
|
||||
message_id: messageId,
|
||||
reactions_summary: reactionsSummary
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Удаление реакции
|
||||
window.addEventListener('ws:reaction-removed', (event) => {
|
||||
const { messageId, reactionsSummary, chatId } = event.detail;
|
||||
if (chatId === this.chatId && messageId) {
|
||||
console.log('[ChatController] WS: reaction-removed, msg=%s', messageId);
|
||||
this.loader.handleReactionRemoved({
|
||||
message_id: messageId,
|
||||
reactions_summary: reactionsSummary
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
console.log('[ChatController] ✅ Subscribed to WebSocket events');
|
||||
}
|
||||
|
||||
// ==================== Публичное API ====================
|
||||
|
||||
/**
|
||||
* Отправляет сообщение
|
||||
* @param {string} content - Содержимое сообщения
|
||||
* @param {Object} options - Дополнительные опции
|
||||
* @returns {string} Временный ID сообщения
|
||||
*/
|
||||
sendMessage(content, options = {}) {
|
||||
if (!content || typeof content !== 'string' || content.trim() === '') {
|
||||
console.warn('[ChatController] Cannot send empty message');
|
||||
return null;
|
||||
}
|
||||
|
||||
console.log('[ChatController] Sending message...');
|
||||
|
||||
const tempId = this.loader.sendMessageOptimistically(
|
||||
this.chatId,
|
||||
content.trim(),
|
||||
options
|
||||
);
|
||||
|
||||
return tempId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Загружает больше истории
|
||||
* @returns {Promise<Array>}
|
||||
*/
|
||||
async loadMoreHistory() {
|
||||
return this.scrollManager.loadMoreHistory();
|
||||
}
|
||||
|
||||
/**
|
||||
* Прокручивает к последнему сообщению
|
||||
* @param {Object} options - Опции
|
||||
*/
|
||||
scrollToBottom(options = {}) {
|
||||
this.scrollManager.scrollToBottom(options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Прокручивает к конкретному сообщению
|
||||
* @param {number|string} messageId - ID сообщения
|
||||
* @param {Object} options - Опции
|
||||
*/
|
||||
scrollToMessage(messageId, options = {}) {
|
||||
this.scrollManager.scrollToMessage(messageId, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Получает сообщение по ID
|
||||
* @param {number|string} messageId - ID сообщения
|
||||
* @returns {Message|null}
|
||||
*/
|
||||
getMessage(messageId) {
|
||||
return this.store.getMessage(messageId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Получает все сообщения чата
|
||||
* @param {Object} options - Опции
|
||||
* @returns {Array<Message>}
|
||||
*/
|
||||
getMessages(options = {}) {
|
||||
return this.store.getMessagesForChat(this.chatId, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Проверяет находится ли пользователь внизу
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isNearBottom() {
|
||||
return this.scrollManager.isNearBottom();
|
||||
}
|
||||
|
||||
/**
|
||||
* Получает статус чата
|
||||
* @returns {Object}
|
||||
*/
|
||||
getStatus() {
|
||||
return {
|
||||
chatId: this.chatId,
|
||||
initialized: this.initialized,
|
||||
messageCount: this.store.getMessageCount(this.chatId),
|
||||
isLoading: this.loader.isLoading(this.chatId),
|
||||
hasMoreHistory: this.loader.hasMoreHistory(this.chatId),
|
||||
scroll: this.scrollManager.getStatus(),
|
||||
store: this.store.getStats(),
|
||||
renderer: this.renderer.getStats()
|
||||
};
|
||||
}
|
||||
|
||||
// ==================== Утилиты ====================
|
||||
|
||||
/**
|
||||
* Отправляет кастомное событие
|
||||
* @private
|
||||
*/
|
||||
_dispatchEvent(eventName, detail) {
|
||||
window.dispatchEvent(new CustomEvent(eventName, { detail }));
|
||||
}
|
||||
}
|
||||
|
||||
// Экспорт для использования в других модулях
|
||||
export default ChatController;
|
||||
470
backend/static/js/controllers/chatControllerV2.js
Normal file
470
backend/static/js/controllers/chatControllerV2.js
Normal file
@ -0,0 +1,470 @@
|
||||
/**
|
||||
* @fileoverview ChatController V2 - рефакторированный координатор чата
|
||||
* @module controllers/chatControllerV2
|
||||
*
|
||||
* РЕФАКТОРИНГ:
|
||||
* - Чистая архитектура с разделением ответственности
|
||||
* - Минимум логирования (только важные события)
|
||||
* - Улучшенная обработка ошибок
|
||||
* - Полная типизация JSDoc
|
||||
* - Поддержка отмены операций
|
||||
*/
|
||||
|
||||
import { MessageStoreV2 } from '../stores/messageStoreV2.js';
|
||||
import { MessageLoaderV2 } from '../loaders/messageLoaderV2.js';
|
||||
import { MessageRendererV2 } from '../renderers/messageRendererV2.js';
|
||||
import { ScrollManagerV2 } from '../managers/scrollManagerV2.js';
|
||||
import { CHAT_EVENTS, WS_EVENTS, SCROLL_CONFIG } from '../config/chatConfig.js';
|
||||
|
||||
/**
|
||||
* @typedef {Object} ChatControllerOptions
|
||||
* @property {number} chatId - ID чата
|
||||
* @property {HTMLElement} scrollElement - Контейнер для скролла
|
||||
* @property {number} currentUserId - ID текущего пользователя
|
||||
* @property {string} [containerId='chatScroll'] - ID контейнера сообщений
|
||||
* @property {Object} [wsConnection] - WebSocket соединение
|
||||
* @property {string} [profileUrl] - URL профиля пользователя
|
||||
* @property {string} [detailUrlTemplate] - Шаблон URL детальной информации
|
||||
* @property {number|null} [lastReadMessageId] - ID последнего прочитанного сообщения
|
||||
*/
|
||||
|
||||
/**
|
||||
* ChatController V2 - главный координатор чата
|
||||
* Единая точка входа для работы с чатом.
|
||||
*/
|
||||
export class ChatControllerV2 {
|
||||
/**
|
||||
* @param {ChatControllerOptions} options
|
||||
*/
|
||||
constructor(options) {
|
||||
this._validateOptions(options);
|
||||
|
||||
// Основные параметры
|
||||
this.chatId = options.chatId;
|
||||
this.currentUserId = options.currentUserId;
|
||||
this.scrollElement = options.scrollElement;
|
||||
this.containerId = options.containerId || 'chatScroll';
|
||||
this.lastReadMessageId = options.lastReadMessageId || null;
|
||||
|
||||
// Создаем компоненты
|
||||
this.store = new MessageStoreV2({ currentUserId: this.currentUserId });
|
||||
|
||||
this.loader = new MessageLoaderV2({
|
||||
store: this.store,
|
||||
wsConnection: options.wsConnection,
|
||||
currentUserId: this.currentUserId
|
||||
});
|
||||
|
||||
this.renderer = new MessageRendererV2({
|
||||
store: this.store,
|
||||
containerId: this.containerId,
|
||||
currentUserId: this.currentUserId,
|
||||
profileUrl: options.profileUrl || '/employees/profile/',
|
||||
detailUrlTemplate: options.detailUrlTemplate || '/employees/detail/0/'
|
||||
});
|
||||
|
||||
this.scrollManager = new ScrollManagerV2({
|
||||
scrollElement: this.scrollElement,
|
||||
loader: this.loader,
|
||||
renderer: this.renderer,
|
||||
store: this.store,
|
||||
chatId: this.chatId
|
||||
});
|
||||
|
||||
// Состояние
|
||||
this._initialized = false;
|
||||
this._initializing = false;
|
||||
this._destroyed = false;
|
||||
|
||||
// Слушатели для cleanup
|
||||
this._eventListeners = [];
|
||||
|
||||
// Подписки
|
||||
this._setupStoreSubscription();
|
||||
if (options.wsConnection) {
|
||||
this._setupWebSocketListeners();
|
||||
}
|
||||
|
||||
console.log('[ChatControllerV2] Created for chat:', this.chatId);
|
||||
}
|
||||
|
||||
// ==================== Публичное API - Жизненный цикл ====================
|
||||
|
||||
/**
|
||||
* Инициализирует чат: загружает сообщения, рендерит, настраивает скролл
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async init() {
|
||||
if (this._initialized) {
|
||||
console.warn('[ChatControllerV2] Already initialized');
|
||||
return;
|
||||
}
|
||||
|
||||
if (this._initializing) {
|
||||
console.warn('[ChatControllerV2] Initialization in progress');
|
||||
return;
|
||||
}
|
||||
|
||||
this._initializing = true;
|
||||
|
||||
try {
|
||||
// 1. Загружаем сообщения
|
||||
const loadResult = await this.loader.loadInitial(this.chatId, {
|
||||
aroundMessageId: this.lastReadMessageId
|
||||
});
|
||||
|
||||
// 2. Скрываем контейнер для плавного рендера
|
||||
this.scrollElement.style.visibility = 'hidden';
|
||||
|
||||
try {
|
||||
// 3. Рендерим сообщения
|
||||
await this.renderer.render(this.chatId);
|
||||
|
||||
// 4. Показываем контейнер
|
||||
this.scrollElement.style.visibility = '';
|
||||
|
||||
// 5. Скроллим к нужной позиции
|
||||
if (loadResult.anchorId) {
|
||||
await this._scrollToMessage(loadResult.anchorId);
|
||||
}
|
||||
|
||||
// 6. Инициализируем ScrollManager
|
||||
await this.scrollManager.init();
|
||||
|
||||
} catch (error) {
|
||||
this.scrollElement.style.visibility = '';
|
||||
throw error;
|
||||
}
|
||||
|
||||
this._initialized = true;
|
||||
this._initializing = false;
|
||||
|
||||
// Dispatch события
|
||||
this._emit(CHAT_EVENTS.INITIALIZED, { chatId: this.chatId });
|
||||
|
||||
console.log('[ChatControllerV2] Initialized successfully');
|
||||
|
||||
} catch (error) {
|
||||
this._initializing = false;
|
||||
console.error('[ChatControllerV2] Initialization failed:', error);
|
||||
this._emit(CHAT_EVENTS.ERROR, { error, phase: 'init' });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Уничтожает контроллер и освобождает ресурсы
|
||||
*/
|
||||
destroy() {
|
||||
if (this._destroyed) return;
|
||||
|
||||
this._destroyed = true;
|
||||
|
||||
// Отменяем активные запросы
|
||||
this.loader.cancelAll(this.chatId);
|
||||
|
||||
// Уничтожаем ScrollManager
|
||||
this.scrollManager?.destroy();
|
||||
|
||||
// Очищаем Store
|
||||
this.store?.clearChat(this.chatId);
|
||||
|
||||
// Очищаем Renderer
|
||||
this.renderer?.clear();
|
||||
|
||||
// Удаляем слушатели событий
|
||||
this._eventListeners.forEach(({ event, handler }) => {
|
||||
window.removeEventListener(event, handler);
|
||||
});
|
||||
this._eventListeners = [];
|
||||
|
||||
console.log('[ChatControllerV2] Destroyed');
|
||||
}
|
||||
|
||||
// ==================== Публичное API - Сообщения ====================
|
||||
|
||||
/**
|
||||
* Отправляет сообщение
|
||||
* @param {string} content - Текст сообщения
|
||||
* @param {Object} [options] - Дополнительные опции
|
||||
* @returns {string|null} Временный ID или null при ошибке
|
||||
*/
|
||||
sendMessage(content, options = {}) {
|
||||
if (!content?.trim()) {
|
||||
console.warn('[ChatControllerV2] Cannot send empty message');
|
||||
return null;
|
||||
}
|
||||
|
||||
const tempId = this.loader.sendOptimistic(this.chatId, content.trim(), options);
|
||||
return tempId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Получает сообщение по ID
|
||||
* @param {number|string} messageId
|
||||
* @returns {Object|null}
|
||||
*/
|
||||
getMessage(messageId) {
|
||||
return this.store.getMessage(messageId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Получает все сообщения чата
|
||||
* @param {Object} [options]
|
||||
* @returns {Array}
|
||||
*/
|
||||
getMessages(options = {}) {
|
||||
return this.store.getMessagesForChat(this.chatId, options);
|
||||
}
|
||||
|
||||
// ==================== Публичное API - Скролл ====================
|
||||
|
||||
/**
|
||||
* Загружает больше истории
|
||||
* @returns {Promise<Array>}
|
||||
*/
|
||||
async loadMoreHistory() {
|
||||
return this.scrollManager.loadMoreHistory();
|
||||
}
|
||||
|
||||
/**
|
||||
* Прокручивает к последнему сообщению
|
||||
* @param {Object} [options]
|
||||
*/
|
||||
scrollToBottom(options = {}) {
|
||||
this.scrollManager.scrollToBottom(options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Прокручивает к конкретному сообщению
|
||||
* @param {number|string} messageId
|
||||
* @param {Object} [options]
|
||||
*/
|
||||
scrollToMessage(messageId, options = {}) {
|
||||
this.scrollManager.scrollToMessage(messageId, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Проверяет находится ли пользователь внизу чата
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isNearBottom() {
|
||||
return this.scrollManager.isNearBottom();
|
||||
}
|
||||
|
||||
// ==================== Публичное API - Состояние ====================
|
||||
|
||||
/**
|
||||
* Получает статус контроллера
|
||||
* @returns {Object}
|
||||
*/
|
||||
getStatus() {
|
||||
return {
|
||||
chatId: this.chatId,
|
||||
initialized: this._initialized,
|
||||
messageCount: this.store.getMessageCount(this.chatId),
|
||||
isLoading: this.loader.isLoading(this.chatId),
|
||||
hasMoreHistory: this.loader.hasMoreHistory(this.chatId),
|
||||
scroll: this.scrollManager.getStatus(),
|
||||
store: this.store.getStats(),
|
||||
renderer: this.renderer.getStats()
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Проверяет инициализирован ли контроллер
|
||||
* @returns {boolean}
|
||||
*/
|
||||
get isInitialized() {
|
||||
return this._initialized;
|
||||
}
|
||||
|
||||
// ==================== Приватные методы - Подписки ====================
|
||||
|
||||
/**
|
||||
* Настраивает подписку на Store
|
||||
* @private
|
||||
*/
|
||||
_setupStoreSubscription() {
|
||||
this.store.subscribe((event, data) => {
|
||||
// Игнорируем события во время инициализации
|
||||
if (this._initializing) return;
|
||||
|
||||
this._handleStoreEvent(event, data);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Обрабатывает события Store
|
||||
* @private
|
||||
*/
|
||||
_handleStoreEvent(event, data) {
|
||||
switch (event) {
|
||||
case 'message_added':
|
||||
this._onMessageAdded(data);
|
||||
break;
|
||||
case 'message_updated':
|
||||
this._onMessageUpdated(data);
|
||||
break;
|
||||
case 'message_removed':
|
||||
this._onMessageRemoved(data);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Обработчик добавления сообщения
|
||||
* @private
|
||||
*/
|
||||
_onMessageAdded(data) {
|
||||
const { message, optimistic } = data;
|
||||
|
||||
if (message.chat_id !== this.chatId) return;
|
||||
|
||||
// Рендерим новое сообщение
|
||||
this.renderer.appendMessage(message, this.chatId);
|
||||
|
||||
// Автоскролл только для наших сообщений
|
||||
if (message.author_id === this.currentUserId && !optimistic) {
|
||||
requestAnimationFrame(() => {
|
||||
this.scrollElement.scrollTop = this.scrollElement.scrollHeight;
|
||||
});
|
||||
}
|
||||
|
||||
this._emit(CHAT_EVENTS.MESSAGE_ADDED, { messageId: message.id, chatId: this.chatId });
|
||||
}
|
||||
|
||||
/**
|
||||
* Обработчик обновления сообщения
|
||||
* @private
|
||||
*/
|
||||
_onMessageUpdated(data) {
|
||||
const { messageId, updates } = data;
|
||||
this.renderer.updateMessage(messageId, updates);
|
||||
this._emit(CHAT_EVENTS.MESSAGE_UPDATED, { messageId, updates, chatId: this.chatId });
|
||||
}
|
||||
|
||||
/**
|
||||
* Обработчик удаления сообщения
|
||||
* @private
|
||||
*/
|
||||
_onMessageRemoved(data) {
|
||||
const { messageId } = data;
|
||||
this.renderer.removeMessage(messageId);
|
||||
this._emit(CHAT_EVENTS.MESSAGE_REMOVED, { messageId, chatId: this.chatId });
|
||||
}
|
||||
|
||||
/**
|
||||
* Настраивает слушатели WebSocket
|
||||
* @private
|
||||
*/
|
||||
_setupWebSocketListeners() {
|
||||
const handlers = [
|
||||
[WS_EVENTS.NEW_MESSAGE, (e) => this._handleWsNewMessage(e.detail)],
|
||||
[WS_EVENTS.MESSAGE_EDITED, (e) => this._handleWsMessageEdited(e.detail)],
|
||||
[WS_EVENTS.MESSAGE_REMOVED, (e) => this._handleWsMessageRemoved(e.detail)],
|
||||
[WS_EVENTS.REACTION_ADDED, (e) => this._handleWsReaction(e.detail)],
|
||||
[WS_EVENTS.REACTION_REMOVED, (e) => this._handleWsReaction(e.detail)]
|
||||
];
|
||||
|
||||
handlers.forEach(([event, handler]) => {
|
||||
window.addEventListener(event, handler);
|
||||
this._eventListeners.push({ event, handler });
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
_handleWsNewMessage(detail) {
|
||||
const { message, chatId } = detail;
|
||||
if (chatId === this.chatId && message) {
|
||||
this.loader.handleNewMessage(message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
_handleWsMessageEdited(detail) {
|
||||
const { message, chatId } = detail;
|
||||
if (chatId === this.chatId && message) {
|
||||
this.loader.handleMessageEdited({ message });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
_handleWsMessageRemoved(detail) {
|
||||
const { messageId, chatId } = detail;
|
||||
if (chatId === this.chatId && messageId) {
|
||||
this.loader.handleMessageRemoved({ message_id: messageId });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
_handleWsReaction(detail) {
|
||||
const { messageId, reactionsSummary, chatId } = detail;
|
||||
if (chatId === this.chatId && messageId) {
|
||||
this.loader.handleReactionChange({
|
||||
message_id: messageId,
|
||||
reactions_summary: reactionsSummary
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Приватные методы - Утилиты ====================
|
||||
|
||||
/**
|
||||
* Валидирует опции конструктора
|
||||
* @private
|
||||
*/
|
||||
_validateOptions(options) {
|
||||
if (!options.chatId) {
|
||||
throw new Error('[ChatControllerV2] chatId is required');
|
||||
}
|
||||
if (!options.scrollElement) {
|
||||
throw new Error('[ChatControllerV2] scrollElement is required');
|
||||
}
|
||||
if (!options.currentUserId) {
|
||||
throw new Error('[ChatControllerV2] currentUserId is required');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Прокручивает к сообщению
|
||||
* @private
|
||||
*/
|
||||
async _scrollToMessage(messageId) {
|
||||
if (!messageId) return false;
|
||||
|
||||
// Ждем завершения рендера
|
||||
await new Promise(resolve => setTimeout(resolve, SCROLL_CONFIG.SCROLL_RESTORE_DELAY));
|
||||
|
||||
const messageEl = this.scrollElement.querySelector(`[data-message-id="${messageId}"]`);
|
||||
|
||||
if (messageEl) {
|
||||
messageEl.scrollIntoView({
|
||||
behavior: 'instant',
|
||||
block: 'center',
|
||||
inline: 'nearest'
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Отправляет событие
|
||||
* @private
|
||||
*/
|
||||
_emit(eventName, detail) {
|
||||
window.dispatchEvent(new CustomEvent(eventName, { detail }));
|
||||
}
|
||||
}
|
||||
|
||||
export default ChatControllerV2;
|
||||
220
backend/static/js/examples/chatControllerUsage.js
Normal file
220
backend/static/js/examples/chatControllerUsage.js
Normal file
@ -0,0 +1,220 @@
|
||||
/**
|
||||
* @fileoverview Пример использования новой архитектуры
|
||||
*
|
||||
* Демонстрирует как использовать ChatController
|
||||
* вместо старых разрозненных модулей
|
||||
*/
|
||||
|
||||
import { ChatController } from './controllers/chatController.js';
|
||||
|
||||
// ==================== СТАРЫЙ СПОСОБ (deprecated) ====================
|
||||
|
||||
/*
|
||||
// Было раньше - много разных модулей:
|
||||
import { initUserWebSocket } from './components/userWebSocket.js';
|
||||
import { MessageRenderer } from './components/messageRenderer.js';
|
||||
import { initChatHistoryLoader } from './components/chatHistoryLoader.js';
|
||||
import { initChatMarkRead } from './components/chatMarkRead.js';
|
||||
|
||||
const scrollEl = document.getElementById('chatScroll');
|
||||
const chatId = parseInt(scrollEl.dataset.chatId);
|
||||
|
||||
// Создаем renderer
|
||||
const messageRenderer = new MessageRenderer({
|
||||
containerId: 'chatScroll',
|
||||
currentUserId: userId,
|
||||
profileUrl: profileUrl,
|
||||
detailUrlTemplate: detailUrlTemplate
|
||||
});
|
||||
|
||||
// Инициализируем WebSocket
|
||||
const userWs = initUserWebSocket({ userId });
|
||||
userWs.configure({
|
||||
chatId,
|
||||
messageRenderer,
|
||||
scrollContainerId: 'chatScroll'
|
||||
});
|
||||
|
||||
// Инициализируем History Loader
|
||||
initChatHistoryLoader({
|
||||
messageRenderer,
|
||||
scrollContainerId: 'chatScroll',
|
||||
fetchUrl: `/api/v1/communications/chats/${chatId}/messages/`
|
||||
});
|
||||
|
||||
// И еще кучу других модулей...
|
||||
*/
|
||||
|
||||
// ==================== НОВЫЙ СПОСОБ (recommended) ====================
|
||||
|
||||
/**
|
||||
* Инициализация чата с новой архитектурой
|
||||
*/
|
||||
async function initChat() {
|
||||
// Получаем данные из DOM
|
||||
const scrollEl = document.getElementById('chatScroll');
|
||||
const chatId = parseInt(scrollEl.dataset.chatId);
|
||||
const currentUserId = parseInt(document.body.dataset.userId);
|
||||
|
||||
// Получаем WebSocket (он уже инициализирован в base.html)
|
||||
const ws = window.userWebSocket;
|
||||
|
||||
// Создаем ChatController - ВСЁ в одном!
|
||||
const chatController = new ChatController({
|
||||
chatId,
|
||||
currentUserId,
|
||||
scrollElement: scrollEl,
|
||||
containerId: 'chatScroll',
|
||||
wsConnection: ws,
|
||||
profileUrl: '/employees/profile/',
|
||||
detailUrlTemplate: '/employees/detail/0/'
|
||||
});
|
||||
|
||||
// Инициализируем (загрузка + рендеринг + scroll)
|
||||
await chatController.init();
|
||||
|
||||
// Готово! ChatController управляет всем:
|
||||
// - MessageStore (хранилище)
|
||||
// - MessageLoader (загрузка)
|
||||
// - MessageRenderer (рендеринг)
|
||||
// - ScrollManager (прокрутка)
|
||||
|
||||
// Экспортируем в window для доступа из других модулей
|
||||
window.chatController = chatController;
|
||||
|
||||
console.log('✅ Chat initialized with new architecture!');
|
||||
console.log('Status:', chatController.getStatus());
|
||||
}
|
||||
|
||||
// ==================== ИСПОЛЬЗОВАНИЕ ====================
|
||||
|
||||
/**
|
||||
* Отправка сообщения
|
||||
*/
|
||||
function sendMessage() {
|
||||
const textarea = document.getElementById('messageInput');
|
||||
const content = textarea.value.trim();
|
||||
|
||||
if (!content) return;
|
||||
|
||||
// Просто вызываем метод контроллера
|
||||
const tempId = window.chatController.sendMessage(content);
|
||||
|
||||
console.log('Message sent with temp ID:', tempId);
|
||||
|
||||
// Очищаем textarea
|
||||
textarea.value = '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Прокрутка к последнему сообщению
|
||||
*/
|
||||
function scrollToLatest() {
|
||||
window.chatController.scrollToBottom({ instant: false });
|
||||
}
|
||||
|
||||
/**
|
||||
* Загрузка истории
|
||||
*/
|
||||
async function loadMore() {
|
||||
const messages = await window.chatController.loadMoreHistory();
|
||||
console.log('Loaded', messages.length, 'historical messages');
|
||||
}
|
||||
|
||||
/**
|
||||
* Получение статуса чата
|
||||
*/
|
||||
function getStatus() {
|
||||
const status = window.chatController.getStatus();
|
||||
console.log('Chat status:', status);
|
||||
return status;
|
||||
}
|
||||
|
||||
/**
|
||||
* Поиск сообщения
|
||||
*/
|
||||
function findMessage(messageId) {
|
||||
const message = window.chatController.getMessage(messageId);
|
||||
console.log('Message:', message);
|
||||
return message;
|
||||
}
|
||||
|
||||
/**
|
||||
* Прокрутка к конкретному сообщению
|
||||
*/
|
||||
function goToMessage(messageId) {
|
||||
window.chatController.scrollToMessage(messageId, {
|
||||
block: 'center',
|
||||
behavior: 'smooth'
|
||||
});
|
||||
}
|
||||
|
||||
// ==================== ИНТЕГРАЦИЯ С ДРУГИМИ МОДУЛЯМИ ====================
|
||||
|
||||
/**
|
||||
* Интеграция с ChatComposer
|
||||
*/
|
||||
function integrateChatComposer() {
|
||||
// ChatComposer теперь может использовать ChatController
|
||||
const composer = {
|
||||
sendMessage: (content, options) => {
|
||||
return window.chatController.sendMessage(content, options);
|
||||
},
|
||||
|
||||
isNearBottom: () => {
|
||||
return window.chatController.isNearBottom();
|
||||
}
|
||||
};
|
||||
|
||||
return composer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Интеграция с реакциями (reactions)
|
||||
*/
|
||||
window.addEventListener('chat:message-added', (event) => {
|
||||
const { messageId, chatId } = event.detail;
|
||||
|
||||
// Инициализируем реакции для нового сообщения
|
||||
console.log('New message added, initializing reactions:', messageId);
|
||||
|
||||
// MessageContextMenu автоматически подхватит через MutationObserver
|
||||
});
|
||||
|
||||
/**
|
||||
* Интеграция с уведомлениями о прочтении
|
||||
*/
|
||||
window.addEventListener('chat:message-added', (event) => {
|
||||
const { messageId } = event.detail;
|
||||
|
||||
// Проверяем видимо ли сообщение
|
||||
const isVisible = window.chatController.scrollManager.isNearBottom();
|
||||
|
||||
if (isVisible) {
|
||||
// Отмечаем как прочитанное
|
||||
// markAsRead(messageId);
|
||||
}
|
||||
});
|
||||
|
||||
// ==================== ЭКСПОРТ ====================
|
||||
|
||||
export {
|
||||
initChat,
|
||||
sendMessage,
|
||||
scrollToLatest,
|
||||
loadMore,
|
||||
getStatus,
|
||||
findMessage,
|
||||
goToMessage,
|
||||
integrateChatComposer
|
||||
};
|
||||
|
||||
// Автозапуск если это страница чата
|
||||
if (document.getElementById('chatScroll')) {
|
||||
// Ждем когда DOM полностью загрузится
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', initChat);
|
||||
} else {
|
||||
initChat();
|
||||
}
|
||||
}
|
||||
433
backend/static/js/loaders/messageLoader.js
Normal file
433
backend/static/js/loaders/messageLoader.js
Normal file
@ -0,0 +1,433 @@
|
||||
/**
|
||||
* @fileoverview MessageLoader - единый загрузчик сообщений
|
||||
* @module loaders/messageLoader
|
||||
*
|
||||
* Отвечает за ВСЕ операции загрузки сообщений:
|
||||
* - Начальная загрузка при открытии чата
|
||||
* - История при scroll up
|
||||
* - Новые сообщения через WebSocket
|
||||
* - Оптимистичная отправка
|
||||
*
|
||||
* Все данные сохраняются в MessageStore.
|
||||
*/
|
||||
|
||||
/**
|
||||
* MessageLoader - единый загрузчик сообщений
|
||||
*/
|
||||
export class MessageLoader {
|
||||
/**
|
||||
* @param {Object} options - Опции конфигурации
|
||||
* @param {MessageStore} options.store - Экземпляр MessageStore
|
||||
* @param {Object} options.wsConnection - WebSocket соединение (userWebSocket)
|
||||
* @param {number} options.currentUserId - ID текущего пользователя
|
||||
*/
|
||||
constructor(options) {
|
||||
if (!options.store) {
|
||||
throw new Error('[MessageLoader] MessageStore is required');
|
||||
}
|
||||
|
||||
this.store = options.store;
|
||||
this.ws = options.wsConnection;
|
||||
this.currentUserId = options.currentUserId;
|
||||
this.currentChatId = null; // Текущий открытый чат
|
||||
|
||||
/** @type {Map<number, {initial: boolean, history: boolean, status: string}>} */
|
||||
this.loadingState = new Map();
|
||||
|
||||
/** @type {Map<number, {hasMore: boolean, oldestId: number|null}>} */
|
||||
this.chatState = new Map();
|
||||
|
||||
console.log('[MessageLoader] Initialized');
|
||||
}
|
||||
|
||||
// ==================== Загрузка начальных сообщений ====================
|
||||
|
||||
/**
|
||||
* Загружает начальные сообщения для чата
|
||||
* НОВАЯ АРХИТЕКТУРА: Загружает ВОКРУГ last_read_message_id
|
||||
* @param {number} chatId - ID чата
|
||||
* @param {Object} options - Опции
|
||||
* @param {number} [options.limit=50] - Количество сообщений (по умолчанию 50)
|
||||
* @param {number} [options.lastReadMessageId] - ID последнего прочитанного (для around)
|
||||
* @returns {Promise<Object>} Результат загрузки
|
||||
*/
|
||||
async loadInitialMessages(chatId, options = {}) {
|
||||
const state = this._getLoadingState(chatId);
|
||||
|
||||
if (state.initial) {
|
||||
return { messages: [], anchorId: null };
|
||||
}
|
||||
|
||||
try {
|
||||
this.currentChatId = chatId;
|
||||
state.initial = true;
|
||||
state.status = 'loading';
|
||||
|
||||
const limit = options.limit || 50;
|
||||
const lastReadId = options.lastReadMessageId;
|
||||
|
||||
// Используем /around/ если есть lastReadId (и он > 0)
|
||||
let url;
|
||||
if (lastReadId && lastReadId > 0) {
|
||||
url = new URL(`/api/v1/communications/chats/${chatId}/messages/around/`, window.location.origin);
|
||||
url.searchParams.set('around_id', String(lastReadId));
|
||||
url.searchParams.set('limit', String(limit));
|
||||
} else {
|
||||
// Fallback: загружаем последние сообщения
|
||||
url = new URL(`/api/v1/communications/chats/${chatId}/messages/`, window.location.origin);
|
||||
url.searchParams.set('limit', String(limit));
|
||||
}
|
||||
|
||||
const response = await fetch(url.toString(), {
|
||||
headers: {
|
||||
'X-Requested-With': 'XMLHttpRequest'
|
||||
},
|
||||
credentials: 'same-origin'
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const messages = data.messages || data.results || [];
|
||||
|
||||
console.log('[MessageLoader] Loaded', messages.length, 'initial messages');
|
||||
|
||||
// Добавляем chat_id к каждому сообщению (API не возвращает его в ответе)
|
||||
messages.forEach(msg => {
|
||||
if (!msg.chat_id) {
|
||||
msg.chat_id = chatId;
|
||||
}
|
||||
});
|
||||
|
||||
// Сохраняем в Store
|
||||
this.store.addMessages(messages);
|
||||
|
||||
// Обновляем состояние чата
|
||||
// Важно: oldestId берем из Store, а не из messages[0], тк Store гарантирует правильную сортировку
|
||||
const oldestMessage = this.store.getOldestMessage(chatId);
|
||||
this._updateChatState(chatId, {
|
||||
hasMore: data.has_more !== false || data.has_more_before !== false,
|
||||
oldestId: oldestMessage ? oldestMessage.id : null
|
||||
});
|
||||
|
||||
state.status = 'loaded';
|
||||
|
||||
return {
|
||||
messages,
|
||||
anchorId: data.anchor_id || null,
|
||||
anchorIndex: data.anchor_index || null,
|
||||
hasMoreBefore: data.has_more_before || false,
|
||||
hasMoreAfter: data.has_more_after || false
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
console.error('[MessageLoader] Failed to load initial messages:', error);
|
||||
state.status = 'error';
|
||||
throw error;
|
||||
} finally {
|
||||
state.initial = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Загрузка истории ====================
|
||||
|
||||
/**
|
||||
* Загружает историю сообщений (при scroll up)
|
||||
* @param {number} chatId - ID чата
|
||||
* @param {Object} options - Опции
|
||||
* @param {number|string} [options.beforeId] - Загрузить до этого сообщения
|
||||
* @param {number} [options.limit=20] - Количество сообщений
|
||||
* @returns {Promise<Array>} Массив сообщений
|
||||
*/
|
||||
async loadHistoryBefore(chatId, options = {}) {
|
||||
const state = this._getLoadingState(chatId);
|
||||
const chatState = this._getChatState(chatId);
|
||||
|
||||
if (state.history) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!chatState.hasMore) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
state.history = true;
|
||||
|
||||
const beforeId = options.beforeId || chatState.oldestId;
|
||||
if (!beforeId) {
|
||||
console.warn('[MessageLoader] No beforeId for history loading');
|
||||
return [];
|
||||
}
|
||||
|
||||
const url = new URL(`/api/v1/communications/chats/${chatId}/messages/`, window.location.origin);
|
||||
url.searchParams.set('before_id', String(beforeId));
|
||||
url.searchParams.set('limit', String(options.limit || 50)); // Telegram-стиль: больше сообщений
|
||||
|
||||
const response = await fetch(url.toString(), {
|
||||
headers: {
|
||||
'X-Requested-With': 'XMLHttpRequest'
|
||||
},
|
||||
credentials: 'same-origin'
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const messages = data.messages || data.results || [];
|
||||
|
||||
// Добавляем chat_id к каждому сообщению
|
||||
messages.forEach(msg => {
|
||||
if (!msg.chat_id) {
|
||||
msg.chat_id = chatId;
|
||||
}
|
||||
});
|
||||
|
||||
// Сохраняем в Store и получаем количество РЕАЛЬНО добавленных
|
||||
const addedCount = messages.length > 0 ? this.store.addMessages(messages) : 0;
|
||||
|
||||
// Обновляем состояние чата
|
||||
if (addedCount > 0) {
|
||||
// Обновляем oldestId - берем из Store
|
||||
const oldestMessage = this.store.getOldestMessage(chatId);
|
||||
this._updateChatState(chatId, {
|
||||
oldestId: oldestMessage ? oldestMessage.id : null,
|
||||
hasMore: data.has_more !== false
|
||||
});
|
||||
} else if (messages.length > 0 && addedCount === 0) {
|
||||
// API вернул сообщения, но все оказались дубликатами
|
||||
this._updateChatState(chatId, { hasMore: false });
|
||||
} else {
|
||||
// Пустой ответ от API - больше нет истории
|
||||
this._updateChatState(chatId, { hasMore: false });
|
||||
}
|
||||
|
||||
// Возвращаем только реально добавленные сообщения
|
||||
return messages.filter(m => this.store.hasMessage(m.id));
|
||||
|
||||
} catch (error) {
|
||||
console.error('[MessageLoader] Failed to load history:', error);
|
||||
throw error;
|
||||
} finally {
|
||||
state.history = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== WebSocket обработчики ====================
|
||||
|
||||
/**
|
||||
* Обрабатывает новое сообщение из WebSocket
|
||||
* @param {Object} message - Сообщение с сервера
|
||||
*/
|
||||
handleNewMessage(message) {
|
||||
if (!message || !message.id) {
|
||||
console.error('[MessageLoader] Invalid message received:', message);
|
||||
return;
|
||||
}
|
||||
|
||||
// Убеждаемся что есть chat_id
|
||||
if (!message.chat_id && this.currentChatId) {
|
||||
message.chat_id = this.currentChatId;
|
||||
}
|
||||
|
||||
// Проверяем - это подтверждение оптимистичного сообщения?
|
||||
if (message.temp_id) {
|
||||
const optimistic = this.store.optimisticMessages.get(message.temp_id);
|
||||
if (optimistic) {
|
||||
this.store.confirmOptimisticMessage(message.temp_id, message);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Обычное новое сообщение
|
||||
this.store.addMessage(message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Обрабатывает редактирование сообщения из WebSocket
|
||||
* @param {Object} data - Данные редактирования
|
||||
*/
|
||||
handleMessageEdited(data) {
|
||||
const { message } = data;
|
||||
if (!message || !message.id) {
|
||||
console.error('[MessageLoader] Invalid edit data:', data);
|
||||
return;
|
||||
}
|
||||
|
||||
this.store.updateMessage(message.id, message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Обрабатывает удаление сообщения из WebSocket
|
||||
* @param {Object} data - Данные удаления
|
||||
*/
|
||||
handleMessageRemoved(data) {
|
||||
const messageId = data.message_id || data.messageId;
|
||||
if (!messageId) {
|
||||
console.error('[MessageLoader] Invalid remove data:', data);
|
||||
return;
|
||||
}
|
||||
|
||||
this.store.removeMessage(messageId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Обрабатывает добавление реакции
|
||||
* @param {Object} data - Данные реакции
|
||||
*/
|
||||
handleReactionAdded(data) {
|
||||
const { message_id, reactions_summary } = data;
|
||||
if (!message_id) return;
|
||||
|
||||
const message = this.store.getMessage(message_id);
|
||||
if (message) {
|
||||
this.store.updateMessage(message_id, { reactions_summary });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Обрабатывает удаление реакции
|
||||
* @param {Object} data - Данные реакции
|
||||
*/
|
||||
handleReactionRemoved(data) {
|
||||
const { message_id, reactions_summary } = data;
|
||||
if (!message_id) return;
|
||||
|
||||
const message = this.store.getMessage(message_id);
|
||||
if (message) {
|
||||
this.store.updateMessage(message_id, { reactions_summary });
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Отправка сообщений ====================
|
||||
|
||||
/**
|
||||
* Отправляет сообщение оптимистично (показываем до подтверждения сервера)
|
||||
* @param {number} chatId - ID чата
|
||||
* @param {string} content - Содержимое сообщения
|
||||
* @param {Object} options - Дополнительные опции
|
||||
* @returns {string} Временный ID сообщения
|
||||
*/
|
||||
sendMessageOptimistically(chatId, content, options = {}) {
|
||||
const tempId = `temp_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||
|
||||
const optimisticMessage = {
|
||||
id: tempId,
|
||||
temp_id: tempId,
|
||||
chat_id: chatId,
|
||||
author_id: this.currentUserId,
|
||||
content,
|
||||
created_ts: Date.now(),
|
||||
status: 'sending',
|
||||
is_edited: false,
|
||||
reactions_summary: {},
|
||||
...options
|
||||
};
|
||||
|
||||
console.log('[MessageLoader] Sending optimistic message:', tempId);
|
||||
|
||||
// Добавляем в Store как оптимистичное
|
||||
this.store.addMessage(optimisticMessage, true);
|
||||
|
||||
// Отправляем через WebSocket (если доступен)
|
||||
if (this.ws && this.ws.send) {
|
||||
try {
|
||||
this.ws.send({
|
||||
type: 'send_message',
|
||||
chat_id: chatId,
|
||||
content,
|
||||
temp_id: tempId,
|
||||
...options
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[MessageLoader] Failed to send via WS:', error);
|
||||
this.store.failOptimisticMessage(tempId);
|
||||
}
|
||||
} else {
|
||||
console.warn('[MessageLoader] WebSocket not available, message not sent');
|
||||
this.store.failOptimisticMessage(tempId);
|
||||
}
|
||||
|
||||
return tempId;
|
||||
}
|
||||
|
||||
// ==================== Утилиты ====================
|
||||
|
||||
/**
|
||||
* Проверяет можно ли загрузить больше истории
|
||||
* @param {number} chatId - ID чата
|
||||
* @returns {boolean}
|
||||
*/
|
||||
hasMoreHistory(chatId) {
|
||||
const chatState = this._getChatState(chatId);
|
||||
return chatState.hasMore;
|
||||
}
|
||||
|
||||
/**
|
||||
* Проверяет загружается ли что-то для чата
|
||||
* @param {number} chatId - ID чата
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isLoading(chatId) {
|
||||
const state = this._getLoadingState(chatId);
|
||||
return state.initial || state.history;
|
||||
}
|
||||
|
||||
/**
|
||||
* Получает статус загрузки для чата
|
||||
* @param {number} chatId - ID чата
|
||||
* @returns {Object}
|
||||
*/
|
||||
getLoadingStatus(chatId) {
|
||||
return {
|
||||
...this._getLoadingState(chatId),
|
||||
...this._getChatState(chatId)
|
||||
};
|
||||
}
|
||||
|
||||
// ==================== Приватные методы ====================
|
||||
|
||||
/**
|
||||
* Получает состояние загрузки для чата
|
||||
* @private
|
||||
*/
|
||||
_getLoadingState(chatId) {
|
||||
if (!this.loadingState.has(chatId)) {
|
||||
this.loadingState.set(chatId, {
|
||||
initial: false,
|
||||
history: false,
|
||||
status: 'idle'
|
||||
});
|
||||
}
|
||||
return this.loadingState.get(chatId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Получает состояние чата
|
||||
* @private
|
||||
*/
|
||||
_getChatState(chatId) {
|
||||
if (!this.chatState.has(chatId)) {
|
||||
this.chatState.set(chatId, {
|
||||
hasMore: true,
|
||||
oldestId: null
|
||||
});
|
||||
}
|
||||
return this.chatState.get(chatId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Обновляет состояние чата
|
||||
* @private
|
||||
*/
|
||||
_updateChatState(chatId, updates) {
|
||||
const state = this._getChatState(chatId);
|
||||
Object.assign(state, updates);
|
||||
}
|
||||
}
|
||||
|
||||
export default MessageLoader;
|
||||
647
backend/static/js/loaders/messageLoaderV2.js
Normal file
647
backend/static/js/loaders/messageLoaderV2.js
Normal file
@ -0,0 +1,647 @@
|
||||
/**
|
||||
* @fileoverview MessageLoader V2 - оптимизированный загрузчик сообщений
|
||||
* @module loaders/messageLoaderV2
|
||||
*
|
||||
* РЕФАКТОРИНГ:
|
||||
* - Единый API для всех типов загрузки
|
||||
* - Retry-логика с exponential backoff
|
||||
* - AbortController для отмены запросов
|
||||
* - Чистое разделение ответственности
|
||||
* - Нет дублирования кода
|
||||
*/
|
||||
|
||||
import {
|
||||
LOADER_CONFIG,
|
||||
API_ENDPOINTS,
|
||||
buildUrl,
|
||||
getRequestHeaders,
|
||||
MESSAGE_STATUS
|
||||
} from '../config/chatConfig.js';
|
||||
|
||||
/**
|
||||
* @typedef {Object} LoadResult
|
||||
* @property {Array<Message>} messages - Загруженные сообщения
|
||||
* @property {number|null} anchorId - ID якорного сообщения
|
||||
* @property {boolean} hasMoreBefore - Есть ли ещё старые сообщения
|
||||
* @property {boolean} hasMoreAfter - Есть ли ещё новые сообщения
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} LoadingState
|
||||
* @property {boolean} initial - Идет начальная загрузка
|
||||
* @property {boolean} history - Идет загрузка истории
|
||||
* @property {boolean} newer - Идет загрузка новых сообщений
|
||||
* @property {string} status - Статус: 'idle' | 'loading' | 'loaded' | 'error'
|
||||
* @property {Error|null} lastError - Последняя ошибка
|
||||
*/
|
||||
|
||||
/**
|
||||
* MessageLoader V2 - современный загрузчик сообщений
|
||||
*/
|
||||
export class MessageLoaderV2 {
|
||||
/**
|
||||
* @param {Object} options - Опции конфигурации
|
||||
* @param {MessageStore} options.store - Экземпляр MessageStore
|
||||
* @param {Object} [options.wsConnection] - WebSocket соединение
|
||||
* @param {number} options.currentUserId - ID текущего пользователя
|
||||
* @param {Object} [options.config] - Переопределение конфигурации
|
||||
*/
|
||||
constructor(options) {
|
||||
if (!options.store) {
|
||||
throw new Error('[MessageLoaderV2] MessageStore is required');
|
||||
}
|
||||
|
||||
this.store = options.store;
|
||||
this.ws = options.wsConnection || null;
|
||||
this.currentUserId = options.currentUserId;
|
||||
|
||||
// Конфигурация с возможностью переопределения
|
||||
this.config = { ...LOADER_CONFIG, ...options.config };
|
||||
|
||||
/** @type {number|null} ID текущего открытого чата */
|
||||
this.currentChatId = null;
|
||||
|
||||
/** @type {Map<number, LoadingState>} Состояние загрузки по чатам */
|
||||
this.loadingStates = new Map();
|
||||
|
||||
/** @type {Map<number, {hasMoreBefore: boolean, hasMoreAfter: boolean, oldestId: number|null, newestId: number|null}>} */
|
||||
this.chatBoundaries = new Map();
|
||||
|
||||
/** @type {Map<string, AbortController>} Активные запросы */
|
||||
this.pendingRequests = new Map();
|
||||
|
||||
console.log('[MessageLoaderV2] Initialized');
|
||||
}
|
||||
|
||||
// ==================== Публичное API ====================
|
||||
|
||||
/**
|
||||
* Загружает начальные сообщения для чата
|
||||
* @param {number} chatId - ID чата
|
||||
* @param {Object} [options] - Опции загрузки
|
||||
* @param {number} [options.aroundMessageId] - Загрузить вокруг этого сообщения
|
||||
* @param {number} [options.limit] - Количество сообщений
|
||||
* @returns {Promise<LoadResult>}
|
||||
*/
|
||||
async loadInitial(chatId, options = {}) {
|
||||
const requestKey = `initial_${chatId}`;
|
||||
|
||||
// Отменяем предыдущий запрос если есть
|
||||
this._abortRequest(requestKey);
|
||||
|
||||
const state = this._getLoadingState(chatId);
|
||||
if (state.initial) {
|
||||
console.log('[MessageLoaderV2] Initial load already in progress for chat:', chatId);
|
||||
return { messages: [], anchorId: null, hasMoreBefore: false, hasMoreAfter: false };
|
||||
}
|
||||
|
||||
this.currentChatId = chatId;
|
||||
state.initial = true;
|
||||
state.status = 'loading';
|
||||
|
||||
try {
|
||||
const { aroundMessageId, limit = this.config.INITIAL_LIMIT } = options;
|
||||
|
||||
let result;
|
||||
if (aroundMessageId && aroundMessageId > 0) {
|
||||
result = await this._loadAround(chatId, aroundMessageId, limit, requestKey);
|
||||
} else {
|
||||
result = await this._loadLatest(chatId, limit, requestKey);
|
||||
}
|
||||
|
||||
// Сохраняем в Store
|
||||
this._processMessages(chatId, result.messages);
|
||||
|
||||
// Обновляем границы чата
|
||||
this._updateBoundaries(chatId, result);
|
||||
|
||||
state.status = 'loaded';
|
||||
state.lastError = null;
|
||||
|
||||
console.log('[MessageLoaderV2] Initial load complete:', result.messages.length, 'messages');
|
||||
|
||||
return result;
|
||||
|
||||
} catch (error) {
|
||||
if (error.name === 'AbortError') {
|
||||
console.log('[MessageLoaderV2] Initial load aborted for chat:', chatId);
|
||||
state.status = 'idle';
|
||||
return { messages: [], anchorId: null, hasMoreBefore: false, hasMoreAfter: false };
|
||||
}
|
||||
|
||||
console.error('[MessageLoaderV2] Initial load failed:', error);
|
||||
state.status = 'error';
|
||||
state.lastError = error;
|
||||
throw error;
|
||||
|
||||
} finally {
|
||||
state.initial = false;
|
||||
this._cleanupRequest(requestKey);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Загружает историю (старые сообщения)
|
||||
* @param {number} chatId - ID чата
|
||||
* @param {Object} [options] - Опции
|
||||
* @param {number} [options.limit] - Количество сообщений
|
||||
* @returns {Promise<Array<Message>>}
|
||||
*/
|
||||
async loadHistory(chatId, options = {}) {
|
||||
const requestKey = `history_${chatId}`;
|
||||
|
||||
const state = this._getLoadingState(chatId);
|
||||
const boundaries = this._getBoundaries(chatId);
|
||||
|
||||
if (state.history) {
|
||||
console.log('[MessageLoaderV2] History load already in progress');
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!boundaries.hasMoreBefore) {
|
||||
console.log('[MessageLoaderV2] No more history available');
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!boundaries.oldestId) {
|
||||
console.log('[MessageLoaderV2] No oldest ID for history loading');
|
||||
return [];
|
||||
}
|
||||
|
||||
this._abortRequest(requestKey);
|
||||
state.history = true;
|
||||
|
||||
try {
|
||||
const limit = options.limit || this.config.HISTORY_LIMIT;
|
||||
const result = await this._fetchWithRetry(
|
||||
buildUrl(API_ENDPOINTS.MESSAGES(chatId), {
|
||||
before_id: boundaries.oldestId,
|
||||
limit
|
||||
}),
|
||||
requestKey
|
||||
);
|
||||
|
||||
const messages = this._extractMessages(result);
|
||||
|
||||
if (messages.length > 0) {
|
||||
this._processMessages(chatId, messages);
|
||||
|
||||
// Обновляем границы
|
||||
const oldestMessage = this.store.getOldestMessage(chatId);
|
||||
this._updateBoundaries(chatId, {
|
||||
hasMoreBefore: result.has_more !== false,
|
||||
oldestId: oldestMessage?.id || null
|
||||
});
|
||||
} else {
|
||||
this._updateBoundaries(chatId, { hasMoreBefore: false });
|
||||
}
|
||||
|
||||
console.log('[MessageLoaderV2] History loaded:', messages.length, 'messages');
|
||||
return messages;
|
||||
|
||||
} catch (error) {
|
||||
if (error.name === 'AbortError') {
|
||||
console.log('[MessageLoaderV2] History load aborted');
|
||||
return [];
|
||||
}
|
||||
console.error('[MessageLoaderV2] History load failed:', error);
|
||||
throw error;
|
||||
|
||||
} finally {
|
||||
state.history = false;
|
||||
this._cleanupRequest(requestKey);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Загружает новые сообщения (после текущего newest)
|
||||
* Используется для синхронизации после reconnect
|
||||
* @param {number} chatId - ID чата
|
||||
* @returns {Promise<Array<Message>>}
|
||||
*/
|
||||
async loadNewer(chatId) {
|
||||
const requestKey = `newer_${chatId}`;
|
||||
|
||||
const state = this._getLoadingState(chatId);
|
||||
const boundaries = this._getBoundaries(chatId);
|
||||
|
||||
if (state.newer || !boundaries.newestId) {
|
||||
return [];
|
||||
}
|
||||
|
||||
this._abortRequest(requestKey);
|
||||
state.newer = true;
|
||||
|
||||
try {
|
||||
const result = await this._fetchWithRetry(
|
||||
buildUrl(API_ENDPOINTS.MESSAGES(chatId), {
|
||||
after_id: boundaries.newestId,
|
||||
limit: this.config.HISTORY_LIMIT
|
||||
}),
|
||||
requestKey
|
||||
);
|
||||
|
||||
const messages = this._extractMessages(result);
|
||||
|
||||
if (messages.length > 0) {
|
||||
this._processMessages(chatId, messages);
|
||||
|
||||
const newestMessage = this.store.getNewestMessage(chatId);
|
||||
this._updateBoundaries(chatId, {
|
||||
hasMoreAfter: result.has_more !== false,
|
||||
newestId: newestMessage?.id || null
|
||||
});
|
||||
}
|
||||
|
||||
return messages;
|
||||
|
||||
} catch (error) {
|
||||
if (error.name !== 'AbortError') {
|
||||
console.error('[MessageLoaderV2] Newer load failed:', error);
|
||||
}
|
||||
return [];
|
||||
|
||||
} finally {
|
||||
state.newer = false;
|
||||
this._cleanupRequest(requestKey);
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== WebSocket обработчики ====================
|
||||
|
||||
/**
|
||||
* Обрабатывает новое сообщение из WebSocket
|
||||
* @param {Object} message - Сообщение с сервера
|
||||
*/
|
||||
handleNewMessage(message) {
|
||||
if (!this._validateMessage(message)) return;
|
||||
|
||||
// Устанавливаем chat_id если отсутствует
|
||||
if (!message.chat_id && this.currentChatId) {
|
||||
message.chat_id = this.currentChatId;
|
||||
}
|
||||
|
||||
// Проверяем - это подтверждение оптимистичного сообщения?
|
||||
if (message.temp_id && this.store.optimisticMessages.has(message.temp_id)) {
|
||||
console.log('[MessageLoaderV2] Confirming optimistic:', message.temp_id);
|
||||
this.store.confirmOptimisticMessage(message.temp_id, message);
|
||||
return;
|
||||
}
|
||||
|
||||
// Добавляем как обычное сообщение
|
||||
this.store.addMessage(message);
|
||||
|
||||
// Обновляем newestId
|
||||
const boundaries = this._getBoundaries(message.chat_id);
|
||||
if (!boundaries.newestId || message.id > boundaries.newestId) {
|
||||
this._updateBoundaries(message.chat_id, { newestId: message.id });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Обрабатывает редактирование сообщения
|
||||
* @param {Object} data - Данные редактирования
|
||||
*/
|
||||
handleMessageEdited(data) {
|
||||
const message = data.message || data;
|
||||
if (!message?.id) {
|
||||
console.error('[MessageLoaderV2] Invalid edit data');
|
||||
return;
|
||||
}
|
||||
this.store.updateMessage(message.id, message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Обрабатывает удаление сообщения
|
||||
* @param {Object} data - Данные удаления
|
||||
*/
|
||||
handleMessageRemoved(data) {
|
||||
const messageId = data.message_id || data.messageId;
|
||||
if (!messageId) {
|
||||
console.error('[MessageLoaderV2] Invalid remove data');
|
||||
return;
|
||||
}
|
||||
this.store.removeMessage(messageId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Обрабатывает изменение реакций
|
||||
* @param {Object} data - Данные реакции
|
||||
*/
|
||||
handleReactionChange(data) {
|
||||
const { message_id, reactions_summary } = data;
|
||||
if (!message_id) return;
|
||||
|
||||
const message = this.store.getMessage(message_id);
|
||||
if (message) {
|
||||
this.store.updateMessage(message_id, { reactions_summary });
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Отправка сообщений ====================
|
||||
|
||||
/**
|
||||
* Отправляет сообщение оптимистично
|
||||
* @param {number} chatId - ID чата
|
||||
* @param {string} content - Текст сообщения
|
||||
* @param {Object} [options] - Дополнительные опции
|
||||
* @returns {string} Временный ID сообщения
|
||||
*/
|
||||
sendOptimistic(chatId, content, options = {}) {
|
||||
const tempId = this._generateTempId();
|
||||
|
||||
const optimisticMessage = {
|
||||
id: tempId,
|
||||
temp_id: tempId,
|
||||
chat_id: chatId,
|
||||
author_id: this.currentUserId,
|
||||
content: content.trim(),
|
||||
created_ts: Date.now(),
|
||||
status: MESSAGE_STATUS.SENDING,
|
||||
is_edited: false,
|
||||
reactions_summary: {},
|
||||
...options
|
||||
};
|
||||
|
||||
// Добавляем в Store
|
||||
this.store.addMessage(optimisticMessage, true);
|
||||
|
||||
// Отправляем через WebSocket
|
||||
if (this.ws?.send) {
|
||||
try {
|
||||
this.ws.send({
|
||||
type: 'send_message',
|
||||
chat_id: chatId,
|
||||
content: content.trim(),
|
||||
temp_id: tempId,
|
||||
...options
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[MessageLoaderV2] WS send failed:', error);
|
||||
this.store.failOptimisticMessage(tempId);
|
||||
}
|
||||
} else {
|
||||
console.warn('[MessageLoaderV2] WebSocket not available');
|
||||
this.store.failOptimisticMessage(tempId);
|
||||
}
|
||||
|
||||
return tempId;
|
||||
}
|
||||
|
||||
// ==================== Проверки состояния ====================
|
||||
|
||||
/**
|
||||
* Проверяет, есть ли еще история для загрузки
|
||||
* @param {number} chatId - ID чата
|
||||
* @returns {boolean}
|
||||
*/
|
||||
hasMoreHistory(chatId) {
|
||||
return this._getBoundaries(chatId).hasMoreBefore;
|
||||
}
|
||||
|
||||
/**
|
||||
* Проверяет, идет ли сейчас какая-либо загрузка
|
||||
* @param {number} chatId - ID чата
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isLoading(chatId) {
|
||||
const state = this._getLoadingState(chatId);
|
||||
return state.initial || state.history || state.newer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Получает полное состояние загрузки
|
||||
* @param {number} chatId - ID чата
|
||||
* @returns {Object}
|
||||
*/
|
||||
getStatus(chatId) {
|
||||
return {
|
||||
...this._getLoadingState(chatId),
|
||||
...this._getBoundaries(chatId)
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Отменяет все активные запросы для чата
|
||||
* @param {number} chatId - ID чата
|
||||
*/
|
||||
cancelAll(chatId) {
|
||||
const prefixes = [`initial_${chatId}`, `history_${chatId}`, `newer_${chatId}`];
|
||||
prefixes.forEach(key => this._abortRequest(key));
|
||||
}
|
||||
|
||||
// ==================== Приватные методы - HTTP ====================
|
||||
|
||||
/**
|
||||
* Загружает сообщения вокруг указанного ID
|
||||
* @private
|
||||
*/
|
||||
async _loadAround(chatId, aroundId, limit, requestKey) {
|
||||
console.log('[MessageLoaderV2] Loading around message:', aroundId);
|
||||
|
||||
const result = await this._fetchWithRetry(
|
||||
buildUrl(API_ENDPOINTS.MESSAGES_AROUND(chatId), {
|
||||
around_id: aroundId,
|
||||
limit
|
||||
}),
|
||||
requestKey
|
||||
);
|
||||
|
||||
return {
|
||||
messages: this._extractMessages(result),
|
||||
anchorId: result.anchor_id || aroundId,
|
||||
anchorIndex: result.anchor_index || null,
|
||||
hasMoreBefore: result.has_more_before !== false,
|
||||
hasMoreAfter: result.has_more_after !== false
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Загружает последние сообщения
|
||||
* @private
|
||||
*/
|
||||
async _loadLatest(chatId, limit, requestKey) {
|
||||
console.log('[MessageLoaderV2] Loading latest messages');
|
||||
|
||||
const result = await this._fetchWithRetry(
|
||||
buildUrl(API_ENDPOINTS.MESSAGES(chatId), { limit }),
|
||||
requestKey
|
||||
);
|
||||
|
||||
return {
|
||||
messages: this._extractMessages(result),
|
||||
anchorId: null,
|
||||
hasMoreBefore: result.has_more !== false,
|
||||
hasMoreAfter: false
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Выполняет fetch с retry-логикой
|
||||
* @private
|
||||
*/
|
||||
async _fetchWithRetry(url, requestKey, attempt = 1) {
|
||||
const controller = new AbortController();
|
||||
this.pendingRequests.set(requestKey, controller);
|
||||
|
||||
// Таймаут
|
||||
const timeoutId = setTimeout(
|
||||
() => controller.abort(),
|
||||
this.config.REQUEST_TIMEOUT
|
||||
);
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
headers: getRequestHeaders(),
|
||||
credentials: 'same-origin',
|
||||
signal: controller.signal
|
||||
});
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
|
||||
} catch (error) {
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
// Не ретраим при отмене
|
||||
if (error.name === 'AbortError') {
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Retry с exponential backoff
|
||||
if (attempt < this.config.MAX_RETRIES) {
|
||||
const delay = this.config.RETRY_DELAY_BASE * Math.pow(2, attempt - 1);
|
||||
console.log(`[MessageLoaderV2] Retry ${attempt}/${this.config.MAX_RETRIES} after ${delay}ms`);
|
||||
|
||||
await this._delay(delay);
|
||||
return this._fetchWithRetry(url, requestKey, attempt + 1);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Приватные методы - State ====================
|
||||
|
||||
/**
|
||||
* Получает или создает состояние загрузки для чата
|
||||
* @private
|
||||
*/
|
||||
_getLoadingState(chatId) {
|
||||
if (!this.loadingStates.has(chatId)) {
|
||||
this.loadingStates.set(chatId, {
|
||||
initial: false,
|
||||
history: false,
|
||||
newer: false,
|
||||
status: 'idle',
|
||||
lastError: null
|
||||
});
|
||||
}
|
||||
return this.loadingStates.get(chatId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Получает или создает границы чата
|
||||
* @private
|
||||
*/
|
||||
_getBoundaries(chatId) {
|
||||
if (!this.chatBoundaries.has(chatId)) {
|
||||
this.chatBoundaries.set(chatId, {
|
||||
hasMoreBefore: true,
|
||||
hasMoreAfter: false,
|
||||
oldestId: null,
|
||||
newestId: null
|
||||
});
|
||||
}
|
||||
return this.chatBoundaries.get(chatId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Обновляет границы чата
|
||||
* @private
|
||||
*/
|
||||
_updateBoundaries(chatId, updates) {
|
||||
const current = this._getBoundaries(chatId);
|
||||
Object.assign(current, updates);
|
||||
}
|
||||
|
||||
// ==================== Приватные методы - Helpers ====================
|
||||
|
||||
/**
|
||||
* Обрабатывает и сохраняет сообщения в Store
|
||||
* @private
|
||||
*/
|
||||
_processMessages(chatId, messages) {
|
||||
// Добавляем chat_id если отсутствует
|
||||
messages.forEach(msg => {
|
||||
if (!msg.chat_id) {
|
||||
msg.chat_id = chatId;
|
||||
}
|
||||
});
|
||||
|
||||
this.store.addMessages(messages);
|
||||
}
|
||||
|
||||
/**
|
||||
* Извлекает массив сообщений из ответа API
|
||||
* @private
|
||||
*/
|
||||
_extractMessages(response) {
|
||||
return response.messages || response.results || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Валидирует сообщение
|
||||
* @private
|
||||
*/
|
||||
_validateMessage(message) {
|
||||
if (!message || !message.id) {
|
||||
console.error('[MessageLoaderV2] Invalid message:', message);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Генерирует временный ID
|
||||
* @private
|
||||
*/
|
||||
_generateTempId() {
|
||||
return `temp_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Отменяет запрос
|
||||
* @private
|
||||
*/
|
||||
_abortRequest(key) {
|
||||
const controller = this.pendingRequests.get(key);
|
||||
if (controller) {
|
||||
controller.abort();
|
||||
this.pendingRequests.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Очищает ссылку на запрос
|
||||
* @private
|
||||
*/
|
||||
_cleanupRequest(key) {
|
||||
this.pendingRequests.delete(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Возвращает Promise с задержкой
|
||||
* @private
|
||||
*/
|
||||
_delay(ms) {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
}
|
||||
|
||||
export default MessageLoaderV2;
|
||||
523
backend/static/js/managers/scrollManager.js
Normal file
523
backend/static/js/managers/scrollManager.js
Normal file
@ -0,0 +1,523 @@
|
||||
/**
|
||||
* @fileoverview ScrollManager - централизованное управление прокруткой
|
||||
* @module managers/scrollManager
|
||||
*
|
||||
* Отвечает за ВСЮ логику скролла в чате:
|
||||
* - Автоматическая загрузка истории при scroll up
|
||||
* - Умный scroll to bottom без прыжков
|
||||
* - Определение "внизу ли пользователь"
|
||||
* - Сохранение позиции при загрузке истории
|
||||
*/
|
||||
|
||||
/**
|
||||
* ScrollManager - управление прокруткой чата
|
||||
*/
|
||||
export class ScrollManager {
|
||||
/**
|
||||
* @param {Object} options - Опции конфигурации
|
||||
* @param {HTMLElement} options.scrollElement - Контейнер для скролла
|
||||
* @param {MessageLoader} options.messageLoader - Загрузчик сообщений
|
||||
* @param {MessageRenderer} options.messageRenderer - Рендерер сообщений
|
||||
* @param {MessageStore} options.messageStore - Store сообщений
|
||||
* @param {number} options.chatId - ID текущего чата
|
||||
*/
|
||||
constructor(options) {
|
||||
if (!options.scrollElement) {
|
||||
throw new Error('[ScrollManager] scrollElement is required');
|
||||
}
|
||||
if (!options.messageLoader) {
|
||||
throw new Error('[ScrollManager] messageLoader is required');
|
||||
}
|
||||
if (!options.messageRenderer) {
|
||||
throw new Error('[ScrollManager] messageRenderer is required');
|
||||
}
|
||||
|
||||
this.scrollEl = options.scrollElement;
|
||||
this.loader = options.messageLoader;
|
||||
this.renderer = options.messageRenderer;
|
||||
this.store = options.messageStore;
|
||||
this.chatId = options.chatId;
|
||||
|
||||
/** @type {IntersectionObserver|null} */
|
||||
this.historyObserver = null;
|
||||
|
||||
/** @type {boolean} */
|
||||
this.isLoadingHistory = false;
|
||||
|
||||
/** @type {number} Порог в пикселях для определения "внизу" */
|
||||
this.bottomThreshold = 100;
|
||||
|
||||
console.log('[ScrollManager] Initialized for chat:', this.chatId);
|
||||
}
|
||||
|
||||
// ==================== Инициализация ====================
|
||||
|
||||
/**
|
||||
* Инициализирует ScrollManager
|
||||
*/
|
||||
async init() {
|
||||
// Даём время на завершение рендера всех сообщений
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
this.setupIntersectionObserver();
|
||||
this.setupScrollListener();
|
||||
console.log('[ScrollManager] Initialized');
|
||||
}
|
||||
|
||||
/**
|
||||
* Останавливает ScrollManager и очищает ресурсы
|
||||
*/
|
||||
destroy() {
|
||||
if (this.historyObserver) {
|
||||
this.historyObserver.disconnect();
|
||||
this.historyObserver = null;
|
||||
}
|
||||
console.log('[ScrollManager] Destroyed');
|
||||
}
|
||||
|
||||
// ==================== IntersectionObserver для автозагрузки ====================
|
||||
|
||||
/**
|
||||
* Настраивает IntersectionObserver для автоматической загрузки истории
|
||||
*/
|
||||
setupIntersectionObserver() {
|
||||
console.log('[ScrollManager] ========================================');
|
||||
console.log('[ScrollManager] setupIntersectionObserver called');
|
||||
|
||||
// Отключаем старый observer если есть
|
||||
if (this.historyObserver) {
|
||||
console.log('[ScrollManager] Disconnecting old observer');
|
||||
this.historyObserver.disconnect();
|
||||
this.historyObserver = null;
|
||||
}
|
||||
|
||||
// Находим первое сообщение (просто .msg - найдет первый с этим классом)
|
||||
const firstMessage = this.scrollEl.querySelector('.msg');
|
||||
|
||||
console.log('[ScrollManager] First message element:', !!firstMessage);
|
||||
console.log('[ScrollManager] Total messages in DOM:', this.scrollEl.querySelectorAll('.msg').length);
|
||||
|
||||
// Логируем реальные классы первых 3 элементов в контейнере
|
||||
const allChildren = this.scrollEl.children;
|
||||
console.log('[ScrollManager] First 3 elements in container:');
|
||||
for (let i = 0; i < Math.min(3, allChildren.length); i++) {
|
||||
const el = allChildren[i];
|
||||
console.log(` [${i}]:`, {
|
||||
tag: el.tagName,
|
||||
classes: el.className,
|
||||
messageId: el.dataset?.messageId
|
||||
});
|
||||
}
|
||||
|
||||
if (!firstMessage) {
|
||||
console.warn('[ScrollManager] ❌ No messages yet, observer NOT set');
|
||||
console.log('[ScrollManager] ========================================');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('[ScrollManager] First message ID:', firstMessage.dataset?.messageId);
|
||||
|
||||
// Создаем observer
|
||||
this.historyObserver = new IntersectionObserver(
|
||||
entries => {
|
||||
entries.forEach(entry => {
|
||||
console.log('[ScrollManager] IntersectionObserver triggered:', {
|
||||
isIntersecting: entry.isIntersecting,
|
||||
isLoadingHistory: this.isLoadingHistory,
|
||||
target: entry.target.dataset?.messageId
|
||||
});
|
||||
|
||||
if (entry.isIntersecting && !this.isLoadingHistory) {
|
||||
console.log('[ScrollManager] ✅ First message visible, loading history...');
|
||||
this.loadMoreHistory();
|
||||
}
|
||||
});
|
||||
},
|
||||
{
|
||||
root: this.scrollEl,
|
||||
threshold: 0.1,
|
||||
rootMargin: '300px 0px 0px 0px' // Telegram-стиль: загружаем намного раньше
|
||||
}
|
||||
);
|
||||
|
||||
this.historyObserver.observe(firstMessage);
|
||||
console.log('[ScrollManager] ✅ IntersectionObserver attached to first message');
|
||||
console.log('[ScrollManager] ========================================');
|
||||
}
|
||||
|
||||
/**
|
||||
* Обновляет observer на новое первое сообщение
|
||||
*/
|
||||
updateObserverTarget() {
|
||||
console.log('[ScrollManager] ========================================');
|
||||
console.log('[ScrollManager] updateObserverTarget called');
|
||||
|
||||
if (!this.historyObserver) {
|
||||
console.warn('[ScrollManager] No observer exists');
|
||||
console.log('[ScrollManager] ========================================');
|
||||
return;
|
||||
}
|
||||
|
||||
// Отключаем от всех
|
||||
this.historyObserver.disconnect();
|
||||
|
||||
// Находим РЕАЛЬНОЕ первое сообщение с data-message-id
|
||||
// Важно: после добавления истории в начало, ПЕРВОЕ сообщение в DOM - это самое старое
|
||||
const firstMessage = this.scrollEl.querySelector('.msg[data-message-id]');
|
||||
|
||||
console.log('[ScrollManager] Found first message:', !!firstMessage);
|
||||
if (firstMessage) {
|
||||
console.log('[ScrollManager] First message ID:', firstMessage.dataset.messageId);
|
||||
this.historyObserver.observe(firstMessage);
|
||||
console.log('[ScrollManager] ✅ Observer updated to new first message');
|
||||
} else {
|
||||
console.warn('[ScrollManager] ❌ No first message found in DOM');
|
||||
}
|
||||
console.log('[ScrollManager] ========================================');
|
||||
}
|
||||
|
||||
// ==================== Загрузка истории ====================
|
||||
|
||||
/**
|
||||
* Загружает больше истории при scroll up
|
||||
*/
|
||||
async loadMoreHistory() {
|
||||
console.log('[ScrollManager] ========================================');
|
||||
console.log('[ScrollManager] loadMoreHistory called');
|
||||
console.log('[ScrollManager] isLoadingHistory:', this.isLoadingHistory);
|
||||
|
||||
if (this.isLoadingHistory) {
|
||||
console.log('[ScrollManager] ❌ Already loading history');
|
||||
console.log('[ScrollManager] ========================================');
|
||||
return;
|
||||
}
|
||||
|
||||
const hasMore = this.loader.hasMoreHistory(this.chatId);
|
||||
console.log('[ScrollManager] hasMoreHistory:', hasMore);
|
||||
|
||||
if (!hasMore) {
|
||||
console.log('[ScrollManager] ❌ No more history available');
|
||||
console.log('[ScrollManager] ========================================');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
this.isLoadingHistory = true;
|
||||
|
||||
// ============================================
|
||||
// ПРАВИЛЬНЫЙ подход из Telegram Web:
|
||||
// 1. Сохраняем scrollHeight и scrollTop ДО добавления элементов
|
||||
// 2. Prepend элементы в начало (НЕ полный re-render!)
|
||||
// 3. Вычисляем разницу scrollHeight
|
||||
// 4. Корректируем scrollTop на эту разницу
|
||||
// ============================================
|
||||
|
||||
// Отключаем smooth scroll чтобы изменения были мгновенными
|
||||
const originalScrollBehavior = this.scrollEl.style.scrollBehavior;
|
||||
this.scrollEl.style.scrollBehavior = 'auto';
|
||||
|
||||
const oldScrollHeight = this.scrollEl.scrollHeight;
|
||||
const oldScrollTop = this.scrollEl.scrollTop;
|
||||
|
||||
console.log('[ScrollManager] ✅ Loading history...', {
|
||||
oldScrollHeight,
|
||||
oldScrollTop
|
||||
});
|
||||
|
||||
// Загружаем историю через Loader (возвращает уже загруженные сообщения)
|
||||
const messages = await this.loader.loadHistoryBefore(this.chatId);
|
||||
|
||||
// Проверяем есть ли еще история ПОСЛЕ загрузки
|
||||
const stillHasMore = this.loader.hasMoreHistory(this.chatId);
|
||||
|
||||
if (messages.length === 0 || !stillHasMore) {
|
||||
console.log('[ScrollManager] ⚠️ No more unique history to load');
|
||||
console.log('[ScrollManager] ========================================');
|
||||
this.isLoadingHistory = false;
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('[ScrollManager] ✅ History loaded:', messages.length, 'messages');
|
||||
|
||||
// КЛЮЧЕВОЙ МОМЕНТ: НЕ делаем полный render!
|
||||
// Создаем fragment с новыми сообщениями
|
||||
const fragment = this.renderer.prependMessages(messages, this.chatId);
|
||||
|
||||
// Если fragment пустой (все сообщения уже отрендерены), выходим
|
||||
if (!fragment.hasChildNodes()) {
|
||||
console.log('[ScrollManager] ⚠️ All messages already rendered');
|
||||
this.scrollEl.style.scrollBehavior = originalScrollBehavior;
|
||||
this.isLoadingHistory = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// ВАЖНО: Проверяем и удаляем потенциальные дубликаты divider
|
||||
// ПЕРЕД prepend, чтобы избежать визуальных скачков
|
||||
const firstFragmentChild = fragment.firstElementChild;
|
||||
if (firstFragmentChild && firstFragmentChild.classList.contains('day-divider')) {
|
||||
const firstContainerChild = this.scrollEl.firstElementChild;
|
||||
if (firstContainerChild && firstContainerChild.classList.contains('day-divider')) {
|
||||
const fragmentDividerText = firstFragmentChild.textContent.trim();
|
||||
const containerDividerText = firstContainerChild.textContent.trim();
|
||||
|
||||
// Если dividers одинаковые, удаляем из fragment
|
||||
if (fragmentDividerText === containerDividerText) {
|
||||
fragment.removeChild(firstFragmentChild);
|
||||
console.log('[ScrollManager] Removed duplicate divider before prepend:', fragmentDividerText);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Prepend fragment в НАЧАЛО контейнера (как в Telegram)
|
||||
this.scrollEl.prepend(fragment);
|
||||
|
||||
// КЛЮЧЕВОЕ ОТЛИЧИЕ Telegram: ДВОЙНОЙ RAF для стабильности!
|
||||
// Первый RAF - браузер обновляет layout
|
||||
// Второй RAF - браузер гарантированно применил все изменения
|
||||
await new Promise(resolve => {
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
// КЛЮЧЕВОЙ МОМЕНТ: вычисляем разницу в высоте
|
||||
const newScrollHeight = this.scrollEl.scrollHeight;
|
||||
const heightDifference = newScrollHeight - oldScrollHeight;
|
||||
|
||||
// Корректируем scrollTop на разницу высоты
|
||||
// Это сохраняет визуальную позицию пользователя ТОЧНО там же
|
||||
// БЕЗ КАКИХ-ЛИБО ПРЫЖКОВ!
|
||||
this.scrollEl.scrollTop = oldScrollTop + heightDifference;
|
||||
|
||||
console.log('[ScrollManager] Scroll restored using height difference:', {
|
||||
oldScrollHeight,
|
||||
newScrollHeight,
|
||||
heightDifference,
|
||||
oldScrollTop,
|
||||
newScrollTop: this.scrollEl.scrollTop
|
||||
});
|
||||
|
||||
// Обновляем observer на новое первое сообщение
|
||||
this.updateObserverTarget();
|
||||
|
||||
// Восстанавливаем scroll-behavior
|
||||
this.scrollEl.style.scrollBehavior = originalScrollBehavior;
|
||||
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('[ScrollManager] Failed to load history:', error);
|
||||
} finally {
|
||||
this.isLoadingHistory = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Scroll управление ====================
|
||||
|
||||
/**
|
||||
* Настраивает слушатель скролла (для будущих фич)
|
||||
*/
|
||||
setupScrollListener() {
|
||||
this.scrollEl.addEventListener('scroll', () => {
|
||||
// Здесь можно добавить логику, например:
|
||||
// - Показать/скрыть кнопку "scroll to bottom"
|
||||
// - Отметка сообщений как прочитанных
|
||||
// - etc.
|
||||
}, { passive: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* Прокручивает чат в самый низ
|
||||
* @param {Object} options - Опции
|
||||
* @param {boolean} [options.instant=false] - Мгновенная прокрутка без анимации
|
||||
* @param {boolean} [options.force=false] - Прокрутить даже если пользователь не внизу
|
||||
*/
|
||||
scrollToBottom(options = {}) {
|
||||
const { instant = false, force = false } = options;
|
||||
|
||||
// Не скроллим если загружается история (кроме force)
|
||||
if (!force && this.isLoadingHistory) {
|
||||
console.log('[ScrollManager] History is loading, not scrolling to bottom');
|
||||
return;
|
||||
}
|
||||
|
||||
// Не скроллим если пользователь читает историю (кроме force)
|
||||
if (!force && !this.isNearBottom()) {
|
||||
console.log('[ScrollManager] User is reading history, not scrolling');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('[ScrollManager] Scrolling to bottom:', { instant, force });
|
||||
|
||||
if (instant) {
|
||||
// Мгновенная прокрутка - используем visibility hiding
|
||||
this.scrollEl.style.visibility = 'hidden';
|
||||
|
||||
// Двойной RAF для гарантии после layout
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
this.scrollEl.scrollTop = this.scrollEl.scrollHeight;
|
||||
this.scrollEl.style.visibility = '';
|
||||
console.log('[ScrollManager] Instant scroll complete');
|
||||
});
|
||||
});
|
||||
} else {
|
||||
// Плавная прокрутка
|
||||
requestAnimationFrame(() => {
|
||||
this.scrollEl.scrollTop = this.scrollEl.scrollHeight;
|
||||
console.log('[ScrollManager] Smooth scroll complete');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Прокручивает к конкретному сообщению
|
||||
* @param {number|string} messageId - ID сообщения
|
||||
* @param {Object} options - Опции
|
||||
* @param {string} [options.block='center'] - Положение ('start', 'center', 'end', 'nearest')
|
||||
* @param {string} [options.behavior='smooth'] - Поведение ('auto', 'smooth')
|
||||
*/
|
||||
scrollToMessage(messageId, options = {}) {
|
||||
const messageEl = this.scrollEl.querySelector(`[data-message-id="${messageId}"]`);
|
||||
|
||||
if (!messageEl) {
|
||||
console.warn('[ScrollManager] Message not found:', messageId);
|
||||
return;
|
||||
}
|
||||
|
||||
messageEl.scrollIntoView({
|
||||
block: options.block || 'center',
|
||||
behavior: options.behavior || 'smooth'
|
||||
});
|
||||
|
||||
console.log('[ScrollManager] Scrolled to message:', messageId);
|
||||
}
|
||||
|
||||
// ==================== Утилиты ====================
|
||||
|
||||
/**
|
||||
* Проверяет находится ли пользователь близко к низу
|
||||
* @param {number} [threshold] - Порог в пикселях (по умолчанию this.bottomThreshold)
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isNearBottom(threshold = null) {
|
||||
const th = threshold !== null ? threshold : this.bottomThreshold;
|
||||
const isNear = (
|
||||
this.scrollEl.scrollTop + this.scrollEl.clientHeight >=
|
||||
this.scrollEl.scrollHeight - th
|
||||
);
|
||||
return isNear;
|
||||
}
|
||||
|
||||
/**
|
||||
* Проверяет находится ли пользователь в самом верху
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isAtTop() {
|
||||
return this.scrollEl.scrollTop <= 10;
|
||||
}
|
||||
|
||||
/**
|
||||
* Получает текущую позицию скролла в процентах
|
||||
* @returns {number} От 0 до 100
|
||||
*/
|
||||
getScrollPercentage() {
|
||||
const scrollable = this.scrollEl.scrollHeight - this.scrollEl.clientHeight;
|
||||
if (scrollable <= 0) return 100;
|
||||
|
||||
return (this.scrollEl.scrollTop / scrollable) * 100;
|
||||
}
|
||||
|
||||
/**
|
||||
* Сохраняет текущую позицию скролла
|
||||
* @returns {Object} Сохраненное состояние
|
||||
*/
|
||||
saveScrollPosition() {
|
||||
return {
|
||||
scrollTop: this.scrollEl.scrollTop,
|
||||
scrollHeight: this.scrollEl.scrollHeight,
|
||||
clientHeight: this.scrollEl.clientHeight
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Восстанавливает позицию скролла
|
||||
* @param {Object} savedPosition - Сохраненное состояние
|
||||
*/
|
||||
restoreScrollPosition(savedPosition) {
|
||||
if (!savedPosition) return;
|
||||
|
||||
const delta = this.scrollEl.scrollHeight - savedPosition.scrollHeight;
|
||||
this.scrollEl.scrollTop = savedPosition.scrollTop + delta;
|
||||
|
||||
console.log('[ScrollManager] Position restored:', {
|
||||
delta,
|
||||
newScrollTop: this.scrollEl.scrollTop
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Получает первое видимое сообщение (элемент DOM)
|
||||
* @private
|
||||
* @returns {HTMLElement|null}
|
||||
*/
|
||||
_getFirstVisibleMessage() {
|
||||
const messages = this.scrollEl.querySelectorAll('.msg[data-message-id]');
|
||||
const containerRect = this.scrollEl.getBoundingClientRect();
|
||||
|
||||
for (const msg of messages) {
|
||||
const rect = msg.getBoundingClientRect();
|
||||
if (rect.top >= containerRect.top && rect.top <= containerRect.bottom) {
|
||||
return msg;
|
||||
}
|
||||
}
|
||||
|
||||
return messages[0] || null; // Если не нашли видимое, берем первое
|
||||
}
|
||||
|
||||
/**
|
||||
* Получает ID первого видимого сообщения
|
||||
* @returns {number|string|null}
|
||||
*/
|
||||
getFirstVisibleMessageId() {
|
||||
const msg = this._getFirstVisibleMessage();
|
||||
return msg ? msg.dataset.messageId : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Получает ID последнего видимого сообщения
|
||||
* @returns {number|string|null}
|
||||
*/
|
||||
getLastVisibleMessageId() {
|
||||
const messages = Array.from(this.scrollEl.querySelectorAll('.msg[data-message-id]'));
|
||||
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
const msg = messages[i];
|
||||
const rect = msg.getBoundingClientRect();
|
||||
const containerRect = this.scrollEl.getBoundingClientRect();
|
||||
|
||||
if (rect.bottom >= containerRect.top && rect.bottom <= containerRect.bottom) {
|
||||
return msg.dataset.messageId;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Получает статус ScrollManager
|
||||
* @returns {Object}
|
||||
*/
|
||||
getStatus() {
|
||||
return {
|
||||
isNearBottom: this.isNearBottom(),
|
||||
isAtTop: this.isAtTop(),
|
||||
scrollPercentage: this.getScrollPercentage(),
|
||||
isLoadingHistory: this.isLoadingHistory,
|
||||
firstVisibleMessage: this.getFirstVisibleMessageId(),
|
||||
lastVisibleMessage: this.getLastVisibleMessageId()
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export default ScrollManager;
|
||||
488
backend/static/js/managers/scrollManagerV2.js
Normal file
488
backend/static/js/managers/scrollManagerV2.js
Normal file
@ -0,0 +1,488 @@
|
||||
/**
|
||||
* @fileoverview ScrollManager V2 - оптимизированное управление прокруткой
|
||||
* @module managers/scrollManagerV2
|
||||
*
|
||||
* РЕФАКТОРИНГ:
|
||||
* - Debounce/throttle для scroll events
|
||||
* - Оптимизированный IntersectionObserver
|
||||
* - Чистый API без дублирования кода
|
||||
* - Поддержка виртуализации (будущее)
|
||||
*/
|
||||
|
||||
import { SCROLL_CONFIG, LOADER_CONFIG } from '../config/chatConfig.js';
|
||||
|
||||
/**
|
||||
* Создает debounced функцию
|
||||
* @param {Function} fn - Функция для debounce
|
||||
* @param {number} delay - Задержка в мс
|
||||
* @returns {Function}
|
||||
*/
|
||||
function debounce(fn, delay) {
|
||||
let timeoutId = null;
|
||||
return function(...args) {
|
||||
if (timeoutId) clearTimeout(timeoutId);
|
||||
timeoutId = setTimeout(() => fn.apply(this, args), delay);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Создает throttled функцию
|
||||
* @param {Function} fn - Функция для throttle
|
||||
* @param {number} limit - Минимальный интервал в мс
|
||||
* @returns {Function}
|
||||
*/
|
||||
function throttle(fn, limit) {
|
||||
let inThrottle = false;
|
||||
return function(...args) {
|
||||
if (!inThrottle) {
|
||||
fn.apply(this, args);
|
||||
inThrottle = true;
|
||||
setTimeout(() => inThrottle = false, limit);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @typedef {Object} ScrollManagerOptions
|
||||
* @property {HTMLElement} scrollElement - Контейнер для скролла
|
||||
* @property {MessageLoaderV2} loader - Загрузчик сообщений
|
||||
* @property {MessageRendererV2} renderer - Рендерер сообщений
|
||||
* @property {MessageStore} store - Store сообщений
|
||||
* @property {number} chatId - ID чата
|
||||
*/
|
||||
|
||||
/**
|
||||
* ScrollManager V2 - управление прокруткой чата
|
||||
*/
|
||||
export class ScrollManagerV2 {
|
||||
/**
|
||||
* @param {ScrollManagerOptions} options
|
||||
*/
|
||||
constructor(options) {
|
||||
this._validateOptions(options);
|
||||
|
||||
this.scrollEl = options.scrollElement;
|
||||
this.loader = options.loader;
|
||||
this.renderer = options.renderer;
|
||||
this.store = options.store;
|
||||
this.chatId = options.chatId;
|
||||
|
||||
// Конфигурация
|
||||
this.config = { ...SCROLL_CONFIG };
|
||||
|
||||
// Состояние
|
||||
this._historyObserver = null;
|
||||
this._isLoadingHistory = false;
|
||||
this._destroyed = false;
|
||||
|
||||
// Bound handlers для cleanup
|
||||
this._boundScrollHandler = null;
|
||||
this._boundResizeHandler = null;
|
||||
|
||||
// Debounced/throttled функции
|
||||
this._debouncedLoadHistory = debounce(
|
||||
() => this._triggerLoadHistory(),
|
||||
LOADER_CONFIG.HISTORY_DEBOUNCE
|
||||
);
|
||||
|
||||
console.log('[ScrollManagerV2] Created for chat:', this.chatId);
|
||||
}
|
||||
|
||||
// ==================== Публичное API ====================
|
||||
|
||||
/**
|
||||
* Инициализирует ScrollManager
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async init() {
|
||||
// Ждем стабилизации DOM
|
||||
await this._waitForLayout();
|
||||
|
||||
this._setupIntersectionObserver();
|
||||
this._setupScrollListener();
|
||||
this._setupResizeListener();
|
||||
|
||||
console.log('[ScrollManagerV2] Initialized');
|
||||
}
|
||||
|
||||
/**
|
||||
* Уничтожает ScrollManager
|
||||
*/
|
||||
destroy() {
|
||||
if (this._destroyed) return;
|
||||
|
||||
this._destroyed = true;
|
||||
|
||||
// Отключаем observer
|
||||
if (this._historyObserver) {
|
||||
this._historyObserver.disconnect();
|
||||
this._historyObserver = null;
|
||||
}
|
||||
|
||||
// Удаляем слушатели
|
||||
if (this._boundScrollHandler) {
|
||||
this.scrollEl.removeEventListener('scroll', this._boundScrollHandler);
|
||||
}
|
||||
if (this._boundResizeHandler) {
|
||||
window.removeEventListener('resize', this._boundResizeHandler);
|
||||
}
|
||||
|
||||
console.log('[ScrollManagerV2] Destroyed');
|
||||
}
|
||||
|
||||
/**
|
||||
* Загружает больше истории
|
||||
* @returns {Promise<Array>}
|
||||
*/
|
||||
async loadMoreHistory() {
|
||||
if (this._isLoadingHistory || this._destroyed) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!this.loader.hasMoreHistory(this.chatId)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
this._isLoadingHistory = true;
|
||||
|
||||
try {
|
||||
// Сохраняем позицию
|
||||
const savedPosition = this._saveScrollPosition();
|
||||
|
||||
// Загружаем историю
|
||||
const messages = await this.loader.loadHistory(this.chatId);
|
||||
|
||||
if (messages.length > 0) {
|
||||
// Ре-рендерим
|
||||
await this.renderer.render(this.chatId);
|
||||
|
||||
// Восстанавливаем позицию
|
||||
this._restoreScrollPosition(savedPosition);
|
||||
|
||||
// Обновляем observer
|
||||
this._updateObserverTarget();
|
||||
}
|
||||
|
||||
return messages;
|
||||
|
||||
} catch (error) {
|
||||
console.error('[ScrollManagerV2] History load failed:', error);
|
||||
return [];
|
||||
|
||||
} finally {
|
||||
this._isLoadingHistory = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Прокручивает к низу чата
|
||||
* @param {Object} [options]
|
||||
* @param {boolean} [options.instant=false] - Без анимации
|
||||
* @param {boolean} [options.force=false] - Игнорировать позицию пользователя
|
||||
*/
|
||||
scrollToBottom(options = {}) {
|
||||
const { instant = false, force = false } = options;
|
||||
|
||||
if (!force && !this.isNearBottom()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (instant) {
|
||||
// Мгновенный скролл с visibility hiding
|
||||
this.scrollEl.style.visibility = 'hidden';
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
this.scrollEl.scrollTop = this.scrollEl.scrollHeight;
|
||||
this.scrollEl.style.visibility = '';
|
||||
});
|
||||
});
|
||||
} else {
|
||||
// Плавный скролл
|
||||
this.scrollEl.scrollTo({
|
||||
top: this.scrollEl.scrollHeight,
|
||||
behavior: 'smooth'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Прокручивает к конкретному сообщению
|
||||
* @param {number|string} messageId - ID сообщения
|
||||
* @param {Object} [options]
|
||||
* @param {string} [options.block='center'] - Позиция в viewport
|
||||
* @param {string} [options.behavior='smooth'] - Тип анимации
|
||||
*/
|
||||
scrollToMessage(messageId, options = {}) {
|
||||
const messageEl = this.scrollEl.querySelector(`[data-message-id="${messageId}"]`);
|
||||
|
||||
if (!messageEl) {
|
||||
console.warn('[ScrollManagerV2] Message not found:', messageId);
|
||||
return;
|
||||
}
|
||||
|
||||
messageEl.scrollIntoView({
|
||||
block: options.block || 'center',
|
||||
behavior: options.behavior || 'smooth',
|
||||
inline: 'nearest'
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Проверяет, находится ли пользователь внизу
|
||||
* @param {number} [threshold] - Порог в пикселях
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isNearBottom(threshold = null) {
|
||||
const th = threshold ?? this.config.BOTTOM_THRESHOLD;
|
||||
return (
|
||||
this.scrollEl.scrollTop + this.scrollEl.clientHeight >=
|
||||
this.scrollEl.scrollHeight - th
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Проверяет, находится ли пользователь вверху
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isAtTop() {
|
||||
return this.scrollEl.scrollTop <= 10;
|
||||
}
|
||||
|
||||
/**
|
||||
* Получает текущий статус
|
||||
* @returns {Object}
|
||||
*/
|
||||
getStatus() {
|
||||
return {
|
||||
isNearBottom: this.isNearBottom(),
|
||||
isAtTop: this.isAtTop(),
|
||||
isLoadingHistory: this._isLoadingHistory,
|
||||
scrollPercentage: this._getScrollPercentage(),
|
||||
firstVisibleMessageId: this._getFirstVisibleMessageId(),
|
||||
lastVisibleMessageId: this._getLastVisibleMessageId()
|
||||
};
|
||||
}
|
||||
|
||||
// ==================== Приватные методы - Setup ====================
|
||||
|
||||
/**
|
||||
* Настраивает IntersectionObserver для автозагрузки истории
|
||||
* @private
|
||||
*/
|
||||
_setupIntersectionObserver() {
|
||||
// Находим первое сообщение
|
||||
const firstMessage = this.scrollEl.querySelector('.msg[data-message-id]');
|
||||
|
||||
if (!firstMessage) {
|
||||
console.log('[ScrollManagerV2] No messages yet, observer not set');
|
||||
return;
|
||||
}
|
||||
|
||||
// Создаем observer
|
||||
this._historyObserver = new IntersectionObserver(
|
||||
(entries) => this._handleIntersection(entries),
|
||||
{
|
||||
root: this.scrollEl,
|
||||
threshold: this.config.OBSERVER_THRESHOLD,
|
||||
rootMargin: this.config.OBSERVER_ROOT_MARGIN
|
||||
}
|
||||
);
|
||||
|
||||
this._historyObserver.observe(firstMessage);
|
||||
console.log('[ScrollManagerV2] Observer attached to first message');
|
||||
}
|
||||
|
||||
/**
|
||||
* Настраивает слушатель скролла
|
||||
* @private
|
||||
*/
|
||||
_setupScrollListener() {
|
||||
this._boundScrollHandler = throttle((e) => {
|
||||
this._onScroll(e);
|
||||
}, 100);
|
||||
|
||||
this.scrollEl.addEventListener('scroll', this._boundScrollHandler, { passive: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* Настраивает слушатель resize
|
||||
* @private
|
||||
*/
|
||||
_setupResizeListener() {
|
||||
this._boundResizeHandler = debounce(() => {
|
||||
this._onResize();
|
||||
}, 200);
|
||||
|
||||
window.addEventListener('resize', this._boundResizeHandler);
|
||||
}
|
||||
|
||||
// ==================== Приватные методы - Handlers ====================
|
||||
|
||||
/**
|
||||
* Обработчик IntersectionObserver
|
||||
* @private
|
||||
*/
|
||||
_handleIntersection(entries) {
|
||||
entries.forEach(entry => {
|
||||
if (entry.isIntersecting && !this._isLoadingHistory) {
|
||||
this._debouncedLoadHistory();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Обработчик скролла
|
||||
* @private
|
||||
*/
|
||||
_onScroll(e) {
|
||||
// Здесь можно добавить логику:
|
||||
// - Показать/скрыть кнопку "scroll to bottom"
|
||||
// - Отслеживание видимых сообщений для mark read
|
||||
// - Отправка событий
|
||||
}
|
||||
|
||||
/**
|
||||
* Обработчик resize
|
||||
* @private
|
||||
*/
|
||||
_onResize() {
|
||||
// Обновляем observer если нужно
|
||||
this._updateObserverTarget();
|
||||
}
|
||||
|
||||
/**
|
||||
* Триггерит загрузку истории
|
||||
* @private
|
||||
*/
|
||||
async _triggerLoadHistory() {
|
||||
await this.loadMoreHistory();
|
||||
}
|
||||
|
||||
// ==================== Приватные методы - Observer ====================
|
||||
|
||||
/**
|
||||
* Обновляет target IntersectionObserver
|
||||
* @private
|
||||
*/
|
||||
_updateObserverTarget() {
|
||||
if (!this._historyObserver) return;
|
||||
|
||||
this._historyObserver.disconnect();
|
||||
|
||||
const firstMessage = this.scrollEl.querySelector('.msg[data-message-id]');
|
||||
if (firstMessage) {
|
||||
this._historyObserver.observe(firstMessage);
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Приватные методы - Scroll Position ====================
|
||||
|
||||
/**
|
||||
* Сохраняет текущую позицию скролла
|
||||
* @private
|
||||
* @returns {Object}
|
||||
*/
|
||||
_saveScrollPosition() {
|
||||
return {
|
||||
scrollTop: this.scrollEl.scrollTop,
|
||||
scrollHeight: this.scrollEl.scrollHeight,
|
||||
clientHeight: this.scrollEl.clientHeight,
|
||||
firstVisibleId: this._getFirstVisibleMessageId()
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Восстанавливает позицию скролла после добавления контента
|
||||
* @private
|
||||
* @param {Object} savedPosition
|
||||
*/
|
||||
_restoreScrollPosition(savedPosition) {
|
||||
if (!savedPosition) return;
|
||||
|
||||
// Используем двойной RAF для гарантии после layout
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
const heightDelta = this.scrollEl.scrollHeight - savedPosition.scrollHeight;
|
||||
this.scrollEl.scrollTop = savedPosition.scrollTop + heightDelta;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ==================== Приватные методы - Utilities ====================
|
||||
|
||||
/**
|
||||
* Валидирует опции конструктора
|
||||
* @private
|
||||
*/
|
||||
_validateOptions(options) {
|
||||
if (!options.scrollElement) {
|
||||
throw new Error('[ScrollManagerV2] scrollElement is required');
|
||||
}
|
||||
if (!options.loader) {
|
||||
throw new Error('[ScrollManagerV2] loader is required');
|
||||
}
|
||||
if (!options.renderer) {
|
||||
throw new Error('[ScrollManagerV2] renderer is required');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ждет стабилизации layout
|
||||
* @private
|
||||
*/
|
||||
_waitForLayout() {
|
||||
return new Promise(resolve => {
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Получает процент прокрутки
|
||||
* @private
|
||||
*/
|
||||
_getScrollPercentage() {
|
||||
const scrollable = this.scrollEl.scrollHeight - this.scrollEl.clientHeight;
|
||||
if (scrollable <= 0) return 100;
|
||||
return Math.round((this.scrollEl.scrollTop / scrollable) * 100);
|
||||
}
|
||||
|
||||
/**
|
||||
* Получает ID первого видимого сообщения
|
||||
* @private
|
||||
*/
|
||||
_getFirstVisibleMessageId() {
|
||||
const containerRect = this.scrollEl.getBoundingClientRect();
|
||||
const messages = this.scrollEl.querySelectorAll('.msg[data-message-id]');
|
||||
|
||||
for (const msg of messages) {
|
||||
const rect = msg.getBoundingClientRect();
|
||||
if (rect.top >= containerRect.top && rect.top <= containerRect.bottom) {
|
||||
return msg.dataset.messageId;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Получает ID последнего видимого сообщения
|
||||
* @private
|
||||
*/
|
||||
_getLastVisibleMessageId() {
|
||||
const containerRect = this.scrollEl.getBoundingClientRect();
|
||||
const messages = Array.from(this.scrollEl.querySelectorAll('.msg[data-message-id]'));
|
||||
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
const rect = messages[i].getBoundingClientRect();
|
||||
if (rect.bottom >= containerRect.top && rect.bottom <= containerRect.bottom) {
|
||||
return messages[i].dataset.messageId;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export default ScrollManagerV2;
|
||||
@ -2,42 +2,46 @@
|
||||
* @fileoverview Chat Detail Page - инициализация страницы детального просмотра чата
|
||||
* @module pages/chatDetail
|
||||
*
|
||||
* Порядок инициализации компонентов:
|
||||
* 1. MessageRenderer - единый источник рендеринга сообщений
|
||||
* 2. FormManager - управление формой редактирования
|
||||
* 3. MarkRead API - отслеживание прочитанных сообщений
|
||||
* 4. Composer - отправка новых сообщений и файлов
|
||||
* 5. HistoryLoader - подгрузка старых сообщений при скролле
|
||||
* 6. WebSocket - real-time сообщения и события
|
||||
* НОВАЯ АРХИТЕКТУРА (v2.0):
|
||||
* Использует единый ChatController для управления всеми аспектами чата:
|
||||
* - MessageStore - централизованное хранилище
|
||||
* - MessageLoader - загрузка и обновления
|
||||
* - MessageRendererV2 - чистый рендеринг
|
||||
* - ScrollManager - умная прокрутка
|
||||
*
|
||||
* РЕФАКТОРИНГ: Унифицированная архитектура загрузки сообщений
|
||||
* - Все компоненты используют единый MessageRenderer
|
||||
* - WebSocket для initial messages
|
||||
* - HTTP для истории (pagination)
|
||||
* - Composer для pending messages
|
||||
* Старые компоненты (Composer, FormManager, MarkRead) остаются для дополнительной функциональности
|
||||
*/
|
||||
|
||||
let initChatMarkRead, initChatComposer, initChatHistoryLoader, initChatFormManager, MessageRenderer;
|
||||
// Асинхронная загрузка модулей
|
||||
(async function() {
|
||||
let initChatMarkRead, initChatComposer, initChatFormManager, ChatController;
|
||||
|
||||
try {
|
||||
const modules = await Promise.all([
|
||||
import('../components/chatMarkRead.js'),
|
||||
import('../components/chatComposer.js'),
|
||||
import('../components/chatHistoryLoader.js'),
|
||||
import('../components/chatFormManager.js'),
|
||||
import('../components/messageRenderer.js')
|
||||
]);
|
||||
|
||||
initChatMarkRead = modules[0].initChatMarkRead;
|
||||
initChatComposer = modules[1].initChatComposer;
|
||||
initChatHistoryLoader = modules[2].initChatHistoryLoader;
|
||||
initChatFormManager = modules[3].initChatFormManager;
|
||||
MessageRenderer = modules[4].MessageRenderer;
|
||||
} catch (error) {
|
||||
console.error('[ChatDetail] Failed to load modules:', error);
|
||||
alert('Ошибка загрузки модулей чата: ' + error.message);
|
||||
throw error;
|
||||
}
|
||||
try {
|
||||
console.log('[ChatDetail] Loading modules...');
|
||||
const modules = await Promise.all([
|
||||
import('../components/chatMarkRead.js'),
|
||||
import('../components/chatComposer.js'),
|
||||
import('../components/chatFormManager.js'),
|
||||
import('../controllers/chatController.js')
|
||||
]);
|
||||
|
||||
console.log('[ChatDetail] Modules loaded:', modules.map(m => Object.keys(m)));
|
||||
|
||||
initChatMarkRead = modules[0].initChatMarkRead;
|
||||
initChatComposer = modules[1].initChatComposer;
|
||||
initChatFormManager = modules[2].initChatFormManager;
|
||||
ChatController = modules[3].ChatController;
|
||||
|
||||
console.log('[ChatDetail] ChatController:', ChatController);
|
||||
|
||||
if (!ChatController) {
|
||||
throw new Error('ChatController is undefined - check export in chatController.js');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[ChatDetail] Failed to load modules:', error);
|
||||
alert('Ошибка загрузки модулей чата: ' + error.message);
|
||||
return; // Прекращаем выполнение
|
||||
}
|
||||
|
||||
/**
|
||||
* Инициализация при готовности DOM
|
||||
@ -62,7 +66,9 @@ function initWhenReady() {
|
||||
messagesUrl: appContainer.dataset.messagesUrl,
|
||||
markReadUrl: appContainer.dataset.markReadUrl,
|
||||
editUrlTemplate: appContainer.dataset.editUrlTemplate,
|
||||
lastReadTimestamp: Number(appContainer.dataset.lastReadTimestamp) || null
|
||||
lastReadTimestamp: Number(appContainer.dataset.lastReadTimestamp) || null,
|
||||
// Правильная обработка 0: проверяем наличие атрибута, а не значение
|
||||
lastReadMessageId: appContainer.dataset.lastReadMessageId ? Number(appContainer.dataset.lastReadMessageId) : null
|
||||
};
|
||||
|
||||
// Читаем дополнительные URL из chatScroll (для рендеринга сообщений)
|
||||
@ -141,24 +147,45 @@ function waitForWebSocket() {
|
||||
* @param {string} config.markReadUrl - URL для отметки прочтения
|
||||
* @param {Object} userWs - API WebSocket соединения
|
||||
*/
|
||||
function initializeComponents(config, userWs) {
|
||||
console.log('[ChatDetail] Initializing components...');
|
||||
async function initializeComponents(config, userWs) {
|
||||
console.log('[ChatDetail] 🚀 Initializing with NEW ChatController architecture...');
|
||||
|
||||
// Получаем avatarMap если доступен
|
||||
const avatarMap = window.chatAvatarMap?.getAll() || {};
|
||||
const scrollElement = document.getElementById('chatScroll');
|
||||
if (!scrollElement) {
|
||||
console.error('[ChatDetail] Scroll container not found');
|
||||
return;
|
||||
}
|
||||
|
||||
// ========== 0. ОТКРЫВАЕМ ЧАТ В WEBSOCKET (ДО ChatController) ==========
|
||||
// ВАЖНО: Открываем ДО инициализации чтобы избежать late initial_messages
|
||||
// loadHistory=false - не загружаем через WS, ChatController загрузит через HTTP
|
||||
if (userWs && userWs.openChat) {
|
||||
userWs.openChat(config.chatId, false);
|
||||
console.log('[ChatDetail] ✅ Chat opened in WebSocket (before init)');
|
||||
}
|
||||
|
||||
// ========== 1. MESSAGE RENDERER ==========
|
||||
// Создается первым, т.к. используется другими компонентами
|
||||
const messageRenderer = new MessageRenderer({
|
||||
containerId: 'chatScroll',
|
||||
// ========== 1. CHAT CONTROLLER (НОВАЯ АРХИТЕКТУРА) ==========
|
||||
// Один контроллер заменяет: MessageRenderer, MessageLoader, ScrollManager, HistoryLoader
|
||||
console.log('[ChatDetail] Creating ChatController...');
|
||||
|
||||
const chatController = new ChatController({
|
||||
chatId: config.chatId,
|
||||
currentUserId: config.userId,
|
||||
currentUserAvatar: config.userAvatar || '',
|
||||
scrollElement: scrollElement,
|
||||
containerId: 'chatScroll',
|
||||
wsConnection: userWs,
|
||||
profileUrl: config.profileUrl || '/employees/profile/',
|
||||
detailUrlTemplate: config.detailUrlTemplate || '/employees/detail/0/'
|
||||
detailUrlTemplate: config.detailUrlTemplate || '/employees/detail/0/',
|
||||
messagesUrl: config.messagesUrl,
|
||||
lastReadTimestamp: config.lastReadMessageId || config.lastReadTimestamp // Используем message_id, fallback на timestamp
|
||||
});
|
||||
console.log('[ChatDetail] ✓ MessageRenderer initialized');
|
||||
|
||||
console.log('[ChatDetail] Initializing ChatController...');
|
||||
await chatController.init();
|
||||
console.log('[ChatDetail] ✅ ChatController initialized');
|
||||
|
||||
// ========== 2. FORM MANAGER ==========
|
||||
// Управление формой редактирования сообщений
|
||||
const formManager = initChatFormManager({
|
||||
formId: 'chatForm',
|
||||
textareaId: 'id_content',
|
||||
@ -171,9 +198,10 @@ function initializeComponents(config, userWs) {
|
||||
console.error('[ChatDetail] FormManager initialization failed');
|
||||
return;
|
||||
}
|
||||
console.log('[ChatDetail] ✓ FormManager initialized');
|
||||
console.log('[ChatDetail] ✅ FormManager initialized');
|
||||
|
||||
// ========== 3. MARK READ API ==========
|
||||
// Отслеживание прочитанных сообщений
|
||||
const markReadApi = initChatMarkRead({
|
||||
chatId: config.chatId,
|
||||
meId: config.userId,
|
||||
@ -184,9 +212,10 @@ function initializeComponents(config, userWs) {
|
||||
markReadUrl: config.markReadUrl,
|
||||
initialLastReadTs: config.lastReadTimestamp
|
||||
});
|
||||
console.log('[ChatDetail] ✓ MarkRead initialized');
|
||||
console.log('[ChatDetail] ✅ MarkRead initialized');
|
||||
|
||||
// ========== 4. COMPOSER ==========
|
||||
// Отправка новых сообщений (использует ChatController для добавления)
|
||||
const composer = initChatComposer({
|
||||
chatId: config.chatId,
|
||||
formId: 'chatForm',
|
||||
@ -195,58 +224,35 @@ function initializeComponents(config, userWs) {
|
||||
meId: config.userId,
|
||||
meName: config.userName,
|
||||
meAvatar: config.userAvatar,
|
||||
messageRenderer: messageRenderer
|
||||
chatController: chatController // НОВОЕ: передаем контроллер вместо renderer
|
||||
});
|
||||
|
||||
if (!composer) {
|
||||
console.error('[ChatDetail] Composer initialization failed');
|
||||
return;
|
||||
}
|
||||
console.log('[ChatDetail] ✓ Composer initialized');
|
||||
console.log('[ChatDetail] ✅ Composer initialized');
|
||||
|
||||
// ========== 5. HISTORY LOADER ==========
|
||||
const historyLoader = initChatHistoryLoader({
|
||||
chatId: config.chatId,
|
||||
scrollContainerId: 'chatScroll',
|
||||
fetchUrl: config.messagesUrl,
|
||||
avatarMap: avatarMap,
|
||||
meId: config.userId,
|
||||
messageRenderer: messageRenderer
|
||||
});
|
||||
console.log('[ChatDetail] ✓ HistoryLoader initialized');
|
||||
|
||||
// ========== 6. CONFIGURE WEBSOCKET ==========
|
||||
if (userWs && userWs.configure) {
|
||||
userWs.configure({
|
||||
scrollContainerId: 'chatScroll',
|
||||
formId: 'chatForm',
|
||||
textareaId: 'id_content',
|
||||
typingIndicatorId: 'typing',
|
||||
avatarMap: avatarMap,
|
||||
markReadApi: markReadApi,
|
||||
messageRenderer: messageRenderer
|
||||
});
|
||||
console.log('[ChatDetail] ✓ WebSocket configured');
|
||||
}
|
||||
|
||||
// ========== 7. OPEN CHAT IN WEBSOCKET ==========
|
||||
// Загружаем начальные сообщения через WebSocket
|
||||
if (userWs && userWs.openChat) {
|
||||
userWs.openChat(config.chatId, true); // true = загружаем initial_messages
|
||||
console.log('[ChatDetail] ✓ Chat opened in WebSocket');
|
||||
}
|
||||
|
||||
// ========== 8. EXPORT APIS TO WINDOW ==========
|
||||
// ========== 5. EXPORT TO WINDOW ==========
|
||||
// Для доступа из других скриптов и отладки
|
||||
window.chatController = chatController;
|
||||
window.chatComposer = composer;
|
||||
window.chatHistoryLoader = historyLoader;
|
||||
window.markReadApi = markReadApi;
|
||||
window.chatFormManager = formManager;
|
||||
window.messageRenderer = messageRenderer;
|
||||
|
||||
// DEPRECATED: Старые API для backward compatibility
|
||||
window.messageRenderer = {
|
||||
renderMessage: (msg) => console.warn('[DEPRECATED] Use chatController instead'),
|
||||
renderMessages: (msgs) => console.warn('[DEPRECATED] Use chatController instead')
|
||||
};
|
||||
window.chatHistoryLoader = {
|
||||
loadMore: () => chatController.loadMoreHistory()
|
||||
};
|
||||
|
||||
console.log('[ChatDetail] ✅ All components initialized successfully');
|
||||
console.log('[ChatDetail] 📊 Available APIs: window.chatController, window.chatComposer, window.markReadApi');
|
||||
|
||||
// Контекстное меню и выделение сообщений инициализируются в chat-detail-enhanced.js
|
||||
|
||||
console.log('[ChatDetail] All components initialized successfully ✓');
|
||||
}
|
||||
}
|
||||
|
||||
})(); // Закрываем async IIFE
|
||||
231
backend/static/js/pages/chatDetailV2.js
Normal file
231
backend/static/js/pages/chatDetailV2.js
Normal file
@ -0,0 +1,231 @@
|
||||
/**
|
||||
* @fileoverview Chat Detail Page V2 - рефакторированная инициализация чата
|
||||
* @module pages/chatDetailV2
|
||||
*
|
||||
* НОВАЯ АРХИТЕКТУРА V2:
|
||||
* - Использует ChatControllerV2 с чистой архитектурой
|
||||
* - MessageStoreV2 с бинарным поиском
|
||||
* - MessageLoaderV2 с retry-логикой
|
||||
* - ScrollManagerV2 с debounce/throttle
|
||||
* - Минимум логирования
|
||||
*/
|
||||
|
||||
(async function() {
|
||||
'use strict';
|
||||
|
||||
// ==================== Загрузка модулей ====================
|
||||
|
||||
let ChatControllerV2, initChatMarkRead, initChatComposer, initChatFormManager;
|
||||
|
||||
try {
|
||||
const [
|
||||
controllerModule,
|
||||
markReadModule,
|
||||
composerModule,
|
||||
formManagerModule
|
||||
] = await Promise.all([
|
||||
import('../controllers/chatControllerV2.js'),
|
||||
import('../components/chatMarkRead.js'),
|
||||
import('../components/chatComposer.js'),
|
||||
import('../components/chatFormManager.js')
|
||||
]);
|
||||
|
||||
ChatControllerV2 = controllerModule.ChatControllerV2;
|
||||
initChatMarkRead = markReadModule.initChatMarkRead;
|
||||
initChatComposer = composerModule.initChatComposer;
|
||||
initChatFormManager = formManagerModule.initChatFormManager;
|
||||
|
||||
if (!ChatControllerV2) {
|
||||
throw new Error('ChatControllerV2 is undefined');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[ChatDetailV2] Failed to load modules:', error);
|
||||
return;
|
||||
}
|
||||
|
||||
// ==================== Конфигурация ====================
|
||||
|
||||
/**
|
||||
* Читает конфигурацию из DOM
|
||||
* @returns {Object|null}
|
||||
*/
|
||||
function readConfig() {
|
||||
const appContainer = document.getElementById('chatDetailApp');
|
||||
if (!appContainer) {
|
||||
console.warn('[ChatDetailV2] App container not found');
|
||||
return null;
|
||||
}
|
||||
|
||||
const scrollContainer = document.getElementById('chatScroll');
|
||||
|
||||
return {
|
||||
chatId: Number(appContainer.dataset.chatId),
|
||||
userId: Number(appContainer.dataset.userId),
|
||||
userName: appContainer.dataset.userName || 'Вы',
|
||||
userAvatar: appContainer.dataset.userAvatar || '',
|
||||
uploadUrl: appContainer.dataset.uploadUrl,
|
||||
messagesUrl: appContainer.dataset.messagesUrl,
|
||||
markReadUrl: appContainer.dataset.markReadUrl,
|
||||
editUrlTemplate: appContainer.dataset.editUrlTemplate,
|
||||
lastReadMessageId: appContainer.dataset.lastReadMessageId
|
||||
? Number(appContainer.dataset.lastReadMessageId)
|
||||
: null,
|
||||
profileUrl: scrollContainer?.dataset.profileUrl || '/employees/profile/',
|
||||
detailUrlTemplate: scrollContainer?.dataset.detailUrlTemplate || '/employees/detail/0/'
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Ждет готовности WebSocket
|
||||
* @param {number} timeout - Таймаут в мс
|
||||
* @returns {Promise<Object>}
|
||||
*/
|
||||
function waitForWebSocket(timeout = 10000) {
|
||||
return new Promise((resolve, reject) => {
|
||||
// Уже готов
|
||||
if (window.userWebSocket?.ws) {
|
||||
resolve(window.userWebSocket);
|
||||
return;
|
||||
}
|
||||
|
||||
let timeoutId;
|
||||
|
||||
const handler = (e) => {
|
||||
clearTimeout(timeoutId);
|
||||
resolve(e.detail.api);
|
||||
};
|
||||
|
||||
window.addEventListener('user:ws-ready', handler, { once: true });
|
||||
|
||||
timeoutId = setTimeout(() => {
|
||||
window.removeEventListener('user:ws-ready', handler);
|
||||
reject(new Error('WebSocket connection timeout'));
|
||||
}, timeout);
|
||||
});
|
||||
}
|
||||
|
||||
// ==================== Инициализация компонентов ====================
|
||||
|
||||
/**
|
||||
* Инициализирует все компоненты чата
|
||||
* @param {Object} config - Конфигурация
|
||||
* @param {Object} wsApi - WebSocket API
|
||||
*/
|
||||
async function initializeComponents(config, wsApi) {
|
||||
const scrollElement = document.getElementById('chatScroll');
|
||||
if (!scrollElement) {
|
||||
console.error('[ChatDetailV2] Scroll container not found');
|
||||
return;
|
||||
}
|
||||
|
||||
// 0. Открываем чат в WebSocket ДО инициализации
|
||||
if (wsApi?.openChat) {
|
||||
wsApi.openChat(config.chatId, false);
|
||||
}
|
||||
|
||||
// 1. Chat Controller (главный координатор)
|
||||
const chatController = new ChatControllerV2({
|
||||
chatId: config.chatId,
|
||||
currentUserId: config.userId,
|
||||
scrollElement,
|
||||
containerId: 'chatScroll',
|
||||
wsConnection: wsApi,
|
||||
profileUrl: config.profileUrl,
|
||||
detailUrlTemplate: config.detailUrlTemplate,
|
||||
lastReadMessageId: config.lastReadMessageId
|
||||
});
|
||||
|
||||
await chatController.init();
|
||||
console.log('[ChatDetailV2] ChatController initialized');
|
||||
|
||||
// 2. Form Manager (редактирование сообщений)
|
||||
const formManager = initChatFormManager({
|
||||
formId: 'chatForm',
|
||||
textareaId: 'id_content',
|
||||
chatId: config.chatId,
|
||||
uploadUrl: config.uploadUrl,
|
||||
editUrlTemplate: config.editUrlTemplate
|
||||
});
|
||||
|
||||
// 3. Mark Read (отслеживание прочитанных)
|
||||
const markReadApi = initChatMarkRead({
|
||||
chatId: config.chatId,
|
||||
meId: config.userId,
|
||||
scrollContainerId: 'chatScroll',
|
||||
textareaId: 'id_content',
|
||||
formId: 'chatForm',
|
||||
scrollBtnId: 'scrollBtn',
|
||||
markReadUrl: config.markReadUrl,
|
||||
initialLastReadTs: config.lastReadMessageId
|
||||
});
|
||||
|
||||
// 4. Composer (отправка сообщений)
|
||||
const composer = initChatComposer({
|
||||
chatId: config.chatId,
|
||||
formId: 'chatForm',
|
||||
textareaId: 'id_content',
|
||||
uploadUrl: config.uploadUrl,
|
||||
meId: config.userId,
|
||||
meName: config.userName,
|
||||
meAvatar: config.userAvatar,
|
||||
chatController
|
||||
});
|
||||
|
||||
// 5. Экспорт в window
|
||||
window.chatController = chatController;
|
||||
window.chatComposer = composer;
|
||||
window.markReadApi = markReadApi;
|
||||
window.chatFormManager = formManager;
|
||||
|
||||
// Backward compatibility
|
||||
window.messageRenderer = {
|
||||
renderMessage: () => console.warn('[DEPRECATED] Use chatController'),
|
||||
renderMessages: () => console.warn('[DEPRECATED] Use chatController')
|
||||
};
|
||||
window.chatHistoryLoader = {
|
||||
loadMore: () => chatController.loadMoreHistory()
|
||||
};
|
||||
|
||||
console.log('[ChatDetailV2] All components initialized');
|
||||
}
|
||||
|
||||
// ==================== Entry Point ====================
|
||||
|
||||
/**
|
||||
* Главная функция инициализации
|
||||
*/
|
||||
async function init() {
|
||||
const config = readConfig();
|
||||
if (!config || !config.chatId) {
|
||||
console.error('[ChatDetailV2] Invalid configuration');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const wsApi = await waitForWebSocket();
|
||||
await initializeComponents(config, wsApi);
|
||||
} catch (error) {
|
||||
console.error('[ChatDetailV2] Initialization failed:', error);
|
||||
|
||||
// Показываем ошибку пользователю
|
||||
const scrollEl = document.getElementById('chatScroll');
|
||||
if (scrollEl) {
|
||||
scrollEl.innerHTML = `
|
||||
<div class="alert alert-danger m-3">
|
||||
<i class="bi bi-exclamation-triangle me-2"></i>
|
||||
Ошибка загрузки чата.
|
||||
<a href="#" onclick="location.reload()">Обновить страницу</a>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Запуск
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', init);
|
||||
} else {
|
||||
init();
|
||||
}
|
||||
|
||||
})();
|
||||
594
backend/static/js/renderers/messageRendererV2.js
Normal file
594
backend/static/js/renderers/messageRendererV2.js
Normal file
@ -0,0 +1,594 @@
|
||||
/**
|
||||
* @fileoverview MessageRenderer V2 - рендеринг из Store
|
||||
* @module renderers/messageRendererV2
|
||||
*
|
||||
* НОВАЯ АРХИТЕКТУРА:
|
||||
* - Рендерит только из MessageStore
|
||||
* - День-разделители вычисляются Store
|
||||
* - Incremental updates (патчинг DOM)
|
||||
* - Нет дублирования логики
|
||||
*/
|
||||
|
||||
/**
|
||||
* Экранирует HTML для безопасного вывода
|
||||
* @param {string} text - Текст для экранирования
|
||||
* @returns {string}
|
||||
*/
|
||||
function escapeHtml(text) {
|
||||
if (!text) return '';
|
||||
const map = {
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": '''
|
||||
};
|
||||
return String(text).replace(/[&<>"']/g, m => map[m]);
|
||||
}
|
||||
|
||||
/**
|
||||
* MessageRenderer V2 - рендерит сообщения из Store
|
||||
*/
|
||||
export class MessageRendererV2 {
|
||||
/**
|
||||
* @param {Object} options - Опции конфигурации
|
||||
* @param {MessageStore} options.store - Экземпляр MessageStore
|
||||
* @param {string} options.containerId - ID контейнера для сообщений
|
||||
* @param {number} options.currentUserId - ID текущего пользователя
|
||||
* @param {string} [options.profileUrl] - URL шаблон профиля
|
||||
* @param {string} [options.detailUrlTemplate] - URL шаблон детальной информации
|
||||
*/
|
||||
constructor(options) {
|
||||
if (!options.store) {
|
||||
throw new Error('[MessageRendererV2] MessageStore is required');
|
||||
}
|
||||
|
||||
this.store = options.store;
|
||||
this.containerId = options.containerId || 'chatScroll';
|
||||
this.currentUserId = options.currentUserId;
|
||||
this.profileUrl = options.profileUrl || '/employees/profile/';
|
||||
this.detailUrlTemplate = options.detailUrlTemplate || '/employees/detail/0/';
|
||||
|
||||
/** @type {Set<number|string>} ID сообщений которые уже в DOM */
|
||||
this.renderedMessages = new Set();
|
||||
|
||||
console.log('[MessageRendererV2] Initialized');
|
||||
}
|
||||
|
||||
// ==================== Основные методы рендеринга ====================
|
||||
|
||||
/**
|
||||
* Рендерит ВСЕ сообщения чата (full render)
|
||||
* @param {number} chatId - ID чата
|
||||
* @param {Object} options - Опции
|
||||
* @param {boolean} [options.clear=true] - Очистить контейнер перед рендерингом
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async render(chatId, options = {}) {
|
||||
const container = document.getElementById(this.containerId);
|
||||
if (!container) {
|
||||
console.error('[MessageRendererV2] Container not found:', this.containerId);
|
||||
return;
|
||||
}
|
||||
|
||||
const { clear = true } = options;
|
||||
|
||||
// Получаем сообщения с dividers из Store
|
||||
const items = this.store.getMessagesWithDividers(chatId);
|
||||
|
||||
if (items.length === 0) {
|
||||
if (clear) {
|
||||
container.innerHTML = '';
|
||||
this.renderedMessages.clear();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Очищаем ДО создания fragment
|
||||
if (clear) {
|
||||
container.innerHTML = '';
|
||||
this.renderedMessages.clear();
|
||||
}
|
||||
|
||||
// Используем DocumentFragment для батчинга
|
||||
const fragment = document.createDocumentFragment();
|
||||
|
||||
items.forEach(item => {
|
||||
if (item.type === 'day-divider') {
|
||||
fragment.appendChild(this._createDayDivider(item.text));
|
||||
} else if (item.type === 'message') {
|
||||
if (!this.renderedMessages.has(item.message.id)) {
|
||||
const messageEl = this._createMessageElement(item.message);
|
||||
fragment.appendChild(messageEl);
|
||||
this.renderedMessages.add(item.message.id);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// ОДНА вставка в DOM
|
||||
container.appendChild(fragment);
|
||||
|
||||
console.log('[MessageRendererV2] Rendered', items.length, 'items');
|
||||
}
|
||||
|
||||
/**
|
||||
* Добавляет старые сообщения в НАЧАЛО (для истории)
|
||||
* @param {Array<Object>} messages - Массив сообщений (от старых к новым)
|
||||
* @param {number} chatId - ID чата
|
||||
* @returns {DocumentFragment} Fragment с элементами для prepend
|
||||
*/
|
||||
prependMessages(messages, chatId) {
|
||||
if (!messages || messages.length === 0) {
|
||||
console.log('[MessageRendererV2] No messages to prepend');
|
||||
return document.createDocumentFragment();
|
||||
}
|
||||
|
||||
console.log('[MessageRendererV2] Prepending', messages.length, 'messages');
|
||||
|
||||
const fragment = document.createDocumentFragment();
|
||||
const items = [];
|
||||
|
||||
// Группируем сообщения по дням (от старых к новым)
|
||||
let lastDay = null;
|
||||
messages.forEach(msg => {
|
||||
const msgDate = new Date(msg.created_ts);
|
||||
const msgDay = this._formatDay(msgDate);
|
||||
|
||||
// Добавляем divider если день изменился
|
||||
if (msgDay !== lastDay) {
|
||||
items.push({ type: 'day-divider', text: msgDay });
|
||||
lastDay = msgDay;
|
||||
}
|
||||
|
||||
items.push({ type: 'message', message: msg });
|
||||
});
|
||||
|
||||
// Создаем элементы
|
||||
items.forEach(item => {
|
||||
if (item.type === 'day-divider') {
|
||||
fragment.appendChild(this._createDayDivider(item.text));
|
||||
} else if (item.type === 'message') {
|
||||
if (!this.renderedMessages.has(item.message.id)) {
|
||||
const messageEl = this._createMessageElement(item.message);
|
||||
fragment.appendChild(messageEl);
|
||||
this.renderedMessages.add(item.message.id);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return fragment;
|
||||
}
|
||||
|
||||
/**
|
||||
* Удаляет дубликаты day-divider после prepend
|
||||
* @param {HTMLElement} container - Контейнер с сообщениями
|
||||
*/
|
||||
_removeDuplicateDividers(container) {
|
||||
const dividers = Array.from(container.querySelectorAll('.day-divider'));
|
||||
const seen = new Set();
|
||||
|
||||
dividers.forEach(divider => {
|
||||
const text = divider.textContent.trim();
|
||||
if (seen.has(text)) {
|
||||
// Удаляем дубликат
|
||||
divider.remove();
|
||||
console.log('[MessageRendererV2] Removed duplicate divider:', text);
|
||||
} else {
|
||||
seen.add(text);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Добавляет одно новое сообщение в конец (incremental)
|
||||
* @param {Object} message - Сообщение
|
||||
* @param {number} chatId - ID чата
|
||||
*/
|
||||
appendMessage(message, chatId) {
|
||||
if (!message || !message.id) {
|
||||
console.error('[MessageRendererV2] Invalid message:', message);
|
||||
return;
|
||||
}
|
||||
|
||||
// Проверяем дубликат
|
||||
if (this.renderedMessages.has(message.id)) {
|
||||
console.log('[MessageRendererV2] Message already rendered:', message.id);
|
||||
return;
|
||||
}
|
||||
|
||||
const container = document.getElementById(this.containerId);
|
||||
if (!container) {
|
||||
console.error('[MessageRendererV2] Container not found:', this.containerId);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('[MessageRendererV2] Appending message:', message.id);
|
||||
|
||||
// Проверяем нужен ли day-divider
|
||||
// Сравниваем с последним сообщением, а не с последним divider
|
||||
const lastMessage = container.querySelector('.msg:last-of-type');
|
||||
if (lastMessage) {
|
||||
const lastMsgId = lastMessage.dataset.messageId;
|
||||
const lastMsgData = this.store.getMessage(lastMsgId);
|
||||
if (lastMsgData) {
|
||||
const lastMsgDate = new Date(lastMsgData.created_ts);
|
||||
const lastMsgDay = this._formatDay(lastMsgDate);
|
||||
const msgDate = new Date(message.created_ts);
|
||||
const msgDay = this._formatDay(msgDate);
|
||||
|
||||
// Добавляем divider только если день изменился
|
||||
if (msgDay !== lastMsgDay) {
|
||||
container.appendChild(this._createDayDivider(msgDay));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Первое сообщение - добавляем divider
|
||||
const msgDate = new Date(message.created_ts);
|
||||
const msgDay = this._formatDay(msgDate);
|
||||
container.appendChild(this._createDayDivider(msgDay));
|
||||
}
|
||||
|
||||
// Создаем и добавляем сообщение
|
||||
const messageEl = this._createMessageElement(message);
|
||||
container.appendChild(messageEl);
|
||||
this.renderedMessages.add(message.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Обновляет существующее сообщение (патчинг)
|
||||
* @param {number|string} messageId - ID сообщения
|
||||
* @param {Partial<Message>} updates - Обновления
|
||||
*/
|
||||
updateMessage(messageId, updates) {
|
||||
const messageEl = document.querySelector(`[data-message-id="${messageId}"]`);
|
||||
if (!messageEl) {
|
||||
console.warn('[MessageRendererV2] Message element not found:', messageId);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('[MessageRendererV2] Updating message:', messageId, updates);
|
||||
|
||||
// Патчим только измененные части
|
||||
if (updates.content !== undefined) {
|
||||
const contentEl = messageEl.querySelector('.message-content');
|
||||
if (contentEl) {
|
||||
contentEl.textContent = updates.content;
|
||||
}
|
||||
}
|
||||
|
||||
if (updates.is_edited !== undefined && updates.is_edited) {
|
||||
messageEl.setAttribute('data-is-edited', 'true');
|
||||
if (updates.edited_at) {
|
||||
messageEl.setAttribute('data-edited-at', String(updates.edited_at));
|
||||
}
|
||||
|
||||
// Добавляем индикатор редактирования
|
||||
const contentEl = messageEl.querySelector('.message-content');
|
||||
if (contentEl && !contentEl.querySelector('.edited-indicator')) {
|
||||
const indicator = document.createElement('small');
|
||||
indicator.className = 'edited-indicator text-muted ms-1';
|
||||
indicator.textContent = '(ред.)';
|
||||
contentEl.appendChild(indicator);
|
||||
}
|
||||
}
|
||||
|
||||
if (updates.reactions_summary !== undefined) {
|
||||
this._updateReactions(messageEl, updates.reactions_summary);
|
||||
}
|
||||
|
||||
if (updates.status !== undefined) {
|
||||
this._updateMessageStatus(messageEl, updates.status);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Удаляет сообщение из DOM
|
||||
* @param {number|string} messageId - ID сообщения
|
||||
*/
|
||||
removeMessage(messageId) {
|
||||
const messageEl = document.querySelector(`[data-message-id="${messageId}"]`);
|
||||
if (!messageEl) {
|
||||
console.warn('[MessageRendererV2] Message element not found:', messageId);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('[MessageRendererV2] Removing message:', messageId);
|
||||
|
||||
messageEl.remove();
|
||||
this.renderedMessages.delete(messageId);
|
||||
}
|
||||
|
||||
// ==================== Создание элементов ====================
|
||||
|
||||
/**
|
||||
* Создает элемент day-divider
|
||||
* @private
|
||||
*/
|
||||
_createDayDivider(text) {
|
||||
const dividerEl = document.createElement('div');
|
||||
dividerEl.className = 'day-divider text-center small text-muted my-3';
|
||||
dividerEl.innerHTML = `<span class="px-3 py-1 rounded-pill bg-light">${escapeHtml(text)}</span>`;
|
||||
return dividerEl;
|
||||
}
|
||||
|
||||
/**
|
||||
* Создает элемент сообщения
|
||||
* @private
|
||||
*/
|
||||
_createMessageElement(msg) {
|
||||
const isOwn = msg.author_id === this.currentUserId;
|
||||
const messageEl = document.createElement('div');
|
||||
|
||||
// Классы
|
||||
const classes = ['d-flex', 'mb-3', 'msg'];
|
||||
classes.push(isOwn ? 'justify-content-end' : 'justify-content-start');
|
||||
|
||||
if (msg.status === 'sending') {
|
||||
classes.push('message-pending');
|
||||
} else if (msg.status === 'failed') {
|
||||
classes.push('message-failed');
|
||||
}
|
||||
|
||||
messageEl.className = classes.join(' ');
|
||||
|
||||
// Атрибуты
|
||||
messageEl.setAttribute('data-id', String(msg.id));
|
||||
messageEl.setAttribute('data-message-id', String(msg.id));
|
||||
messageEl.setAttribute('data-ts', String(msg.created_ts));
|
||||
messageEl.setAttribute('data-author-id', String(msg.author_id || ''));
|
||||
messageEl.setAttribute('data-is-edited', String(msg.is_edited || false));
|
||||
|
||||
if (msg.edited_at) {
|
||||
messageEl.setAttribute('data-edited-at', String(msg.edited_at));
|
||||
}
|
||||
|
||||
if (msg.reactions_summary) {
|
||||
messageEl.setAttribute('data-reactions', JSON.stringify(msg.reactions_summary));
|
||||
}
|
||||
|
||||
// HTML содержимое
|
||||
messageEl.innerHTML = this._buildMessageInnerHtml(msg, isOwn);
|
||||
|
||||
return messageEl;
|
||||
}
|
||||
|
||||
/**
|
||||
* Создает внутренний HTML сообщения
|
||||
* @private
|
||||
*/
|
||||
_buildMessageInnerHtml(msg, isOwn) {
|
||||
const authorName = escapeHtml(msg.author_name || 'Неизвестный');
|
||||
const content = escapeHtml(msg.content || '');
|
||||
const time = this._formatTime(msg.created_ts);
|
||||
|
||||
// Определяем URL автора
|
||||
let authorUrl = this.profileUrl;
|
||||
if (msg.author_id && this.detailUrlTemplate) {
|
||||
authorUrl = this.detailUrlTemplate.replace('/0/', `/${msg.author_id}/`);
|
||||
}
|
||||
|
||||
// Базовая структура
|
||||
let html = `
|
||||
<div class="message-bubble ${isOwn ? 'bg-primary text-white' : 'bg-light'} rounded-3 p-2 position-relative">
|
||||
${!isOwn ? `
|
||||
<div class="message-author mb-1">
|
||||
<a href="${authorUrl}" class="text-decoration-none fw-bold ${isOwn ? 'text-white' : 'text-primary'}">
|
||||
${authorName}
|
||||
</a>
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
<div class="message-content">${content}${msg.is_edited ? '<small class="edited-indicator text-muted ms-1">(ред.)</small>' : ''}</div>
|
||||
|
||||
${msg.attachments && msg.attachments.length > 0 ? `
|
||||
<div class="message-attachments mt-2">
|
||||
${msg.attachments.map(att => this._renderAttachment(att, isOwn)).join('')}
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
<div class="message-time small ${isOwn ? 'text-white-50' : 'text-muted'} mt-1">
|
||||
${time}
|
||||
${msg.status === 'sending' ? '<i class="bi bi-clock ms-1"></i>' : ''}
|
||||
${msg.status === 'failed' ? '<i class="bi bi-exclamation-circle text-danger ms-1"></i>' : ''}
|
||||
</div>
|
||||
|
||||
${msg.reactions_summary && Object.keys(msg.reactions_summary).length > 0 ? `
|
||||
<div class="message-reactions mt-2">
|
||||
${this._renderReactions(msg.reactions_summary)}
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
`;
|
||||
|
||||
return html;
|
||||
}
|
||||
|
||||
/**
|
||||
* Рендерит вложения
|
||||
* @private
|
||||
*/
|
||||
_renderAttachment(att, isOwn) {
|
||||
if (!att) return '';
|
||||
|
||||
const fileName = escapeHtml(att.file_name || att.name || 'Файл');
|
||||
const fileUrl = att.file_url || att.url || '#';
|
||||
const fileSize = att.file_size || att.size || 0;
|
||||
const fileType = att.file_type || att.type || '';
|
||||
|
||||
// Форматируем размер
|
||||
const sizeStr = fileSize > 0
|
||||
? `${(fileSize / 1024).toFixed(1)} KB`
|
||||
: '';
|
||||
|
||||
// Определяем иконку по типу
|
||||
let icon = 'bi-file-earmark';
|
||||
if (fileType.startsWith('image/')) {
|
||||
icon = 'bi-file-image';
|
||||
} else if (fileType.startsWith('video/')) {
|
||||
icon = 'bi-file-play';
|
||||
} else if (fileType.includes('pdf')) {
|
||||
icon = 'bi-file-pdf';
|
||||
} else if (fileType.includes('word') || fileType.includes('document')) {
|
||||
icon = 'bi-file-word';
|
||||
}
|
||||
|
||||
return `
|
||||
<a href="${fileUrl}"
|
||||
target="_blank"
|
||||
class="attachment-link d-inline-flex align-items-center text-decoration-none ${isOwn ? 'text-white' : 'text-dark'} mb-1">
|
||||
<i class="bi ${icon} me-2"></i>
|
||||
<span class="attachment-name">${fileName}</span>
|
||||
${sizeStr ? `<span class="attachment-size ms-2 small opacity-75">(${sizeStr})</span>` : ''}
|
||||
</a>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Рендерит реакции
|
||||
* @private
|
||||
*/
|
||||
_renderReactions(reactions) {
|
||||
if (!reactions || typeof reactions !== 'object') return '';
|
||||
|
||||
return Object.entries(reactions)
|
||||
.map(([emoji, count]) => {
|
||||
if (count > 0) {
|
||||
return `<span class="reaction-badge badge bg-secondary me-1">${escapeHtml(emoji)} ${count}</span>`;
|
||||
}
|
||||
return '';
|
||||
})
|
||||
.join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* Обновляет реакции сообщения
|
||||
* @private
|
||||
*/
|
||||
_updateReactions(messageEl, reactions) {
|
||||
let reactionsContainer = messageEl.querySelector('.message-reactions');
|
||||
|
||||
if (!reactions || Object.keys(reactions).length === 0) {
|
||||
// Удаляем контейнер если нет реакций
|
||||
if (reactionsContainer) {
|
||||
reactionsContainer.remove();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const reactionsHtml = this._renderReactions(reactions);
|
||||
|
||||
if (!reactionsContainer) {
|
||||
// Создаем контейнер
|
||||
const bubble = messageEl.querySelector('.message-bubble');
|
||||
if (bubble) {
|
||||
reactionsContainer = document.createElement('div');
|
||||
reactionsContainer.className = 'message-reactions mt-2';
|
||||
bubble.appendChild(reactionsContainer);
|
||||
}
|
||||
}
|
||||
|
||||
if (reactionsContainer) {
|
||||
reactionsContainer.innerHTML = reactionsHtml;
|
||||
}
|
||||
|
||||
// Обновляем data-атрибут
|
||||
messageEl.setAttribute('data-reactions', JSON.stringify(reactions));
|
||||
}
|
||||
|
||||
/**
|
||||
* Обновляет статус сообщения (sending/sent/failed)
|
||||
* @private
|
||||
*/
|
||||
_updateMessageStatus(messageEl, status) {
|
||||
// Убираем все статусные классы
|
||||
messageEl.classList.remove('message-pending', 'message-failed');
|
||||
|
||||
// Добавляем нужный
|
||||
if (status === 'sending') {
|
||||
messageEl.classList.add('message-pending');
|
||||
} else if (status === 'failed') {
|
||||
messageEl.classList.add('message-failed');
|
||||
}
|
||||
|
||||
// Обновляем иконку в времени
|
||||
const timeEl = messageEl.querySelector('.message-time');
|
||||
if (timeEl) {
|
||||
// Убираем старые иконки
|
||||
const icons = timeEl.querySelectorAll('i');
|
||||
icons.forEach(icon => icon.remove());
|
||||
|
||||
// Добавляем новую
|
||||
if (status === 'sending') {
|
||||
timeEl.innerHTML += ' <i class="bi bi-clock ms-1"></i>';
|
||||
} else if (status === 'failed') {
|
||||
timeEl.innerHTML += ' <i class="bi bi-exclamation-circle text-danger ms-1"></i>';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Утилиты ====================
|
||||
|
||||
/**
|
||||
* Форматирует дату в текст дня
|
||||
* @private
|
||||
*/
|
||||
_formatDay(date) {
|
||||
const today = new Date();
|
||||
const yesterday = new Date(today);
|
||||
yesterday.setDate(yesterday.getDate() - 1);
|
||||
|
||||
const isToday = date.toDateString() === today.toDateString();
|
||||
const isYesterday = date.toDateString() === yesterday.toDateString();
|
||||
|
||||
if (isToday) return 'Сегодня';
|
||||
if (isYesterday) return 'Вчера';
|
||||
|
||||
const options = { day: 'numeric', month: 'long', year: 'numeric' };
|
||||
return date.toLocaleDateString('ru-RU', options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Форматирует время
|
||||
* @private
|
||||
*/
|
||||
_formatTime(timestamp) {
|
||||
const date = new Date(timestamp);
|
||||
const hours = String(date.getHours()).padStart(2, '0');
|
||||
const minutes = String(date.getMinutes()).padStart(2, '0');
|
||||
return `${hours}:${minutes}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Очищает контейнер
|
||||
*/
|
||||
clear() {
|
||||
const container = document.getElementById(this.containerId);
|
||||
if (container) {
|
||||
container.innerHTML = '';
|
||||
this.renderedMessages.clear();
|
||||
console.log('[MessageRendererV2] Container cleared');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Проверяет отрендерено ли сообщение
|
||||
* @param {number|string} messageId - ID сообщения
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isRendered(messageId) {
|
||||
return this.renderedMessages.has(messageId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Получает статистику
|
||||
* @returns {Object}
|
||||
*/
|
||||
getStats() {
|
||||
return {
|
||||
renderedCount: this.renderedMessages.size,
|
||||
containerId: this.containerId
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export default MessageRendererV2;
|
||||
582
backend/static/js/stores/messageStore.js
Normal file
582
backend/static/js/stores/messageStore.js
Normal file
@ -0,0 +1,582 @@
|
||||
/**
|
||||
* @fileoverview MessageStore - централизованное хранилище сообщений
|
||||
* @module stores/messageStore
|
||||
*
|
||||
* Реализует паттерн Single Source of Truth для всех сообщений в приложении.
|
||||
* Все операции с сообщениями проходят через Store, что обеспечивает:
|
||||
* - Отсутствие дубликатов
|
||||
* - Предсказуемое состояние
|
||||
* - Легкое кэширование
|
||||
* - Централизованные уведомления об изменениях
|
||||
*/
|
||||
|
||||
/**
|
||||
* Тип события изменения Store
|
||||
* @typedef {'message_added' | 'message_updated' | 'message_removed' | 'messages_loaded'} StoreEvent
|
||||
*/
|
||||
|
||||
/**
|
||||
* Формат сообщения в Store
|
||||
* @typedef {Object} Message
|
||||
* @property {number|string} id - ID сообщения
|
||||
* @property {number} chat_id - ID чата
|
||||
* @property {number} author_id - ID автора
|
||||
* @property {string} content - Содержимое сообщения
|
||||
* @property {number} created_ts - Timestamp создания (миллисекунды)
|
||||
* @property {boolean} [is_edited] - Отредактировано ли
|
||||
* @property {number} [edited_at] - Timestamp редактирования
|
||||
* @property {Object} [reactions_summary] - Реакции на сообщение
|
||||
* @property {string} [status] - Статус ('sending', 'sent', 'failed')
|
||||
* @property {string} [temp_id] - Временный ID для оптимистичных обновлений
|
||||
*/
|
||||
|
||||
/**
|
||||
* MessageStore - централизованное хранилище сообщений
|
||||
*/
|
||||
export class MessageStore {
|
||||
/**
|
||||
* @param {Object} options - Опции конфигурации
|
||||
* @param {number} options.currentUserId - ID текущего пользователя
|
||||
*/
|
||||
constructor(options = {}) {
|
||||
/** @type {Map<number|string, Message>} */
|
||||
this.messages = new Map();
|
||||
|
||||
/** @type {Map<number, Array<number|string>>} Отсортированные ID сообщений по чатам */
|
||||
this.chatMessages = new Map();
|
||||
|
||||
/** @type {Set<Function>} Слушатели изменений */
|
||||
this.listeners = new Set();
|
||||
|
||||
/** @type {Map<string, Message>} Оптимистичные сообщения (еще не подтверждены сервером) */
|
||||
this.optimisticMessages = new Map();
|
||||
|
||||
this.currentUserId = options.currentUserId;
|
||||
|
||||
console.log('[MessageStore] Initialized');
|
||||
}
|
||||
|
||||
// ==================== Управление сообщениями ====================
|
||||
|
||||
/**
|
||||
* Добавляет сообщение в Store
|
||||
* @param {Message} message - Сообщение для добавления
|
||||
* @param {boolean} optimistic - Является ли оптимистичным (не подтверждено сервером)
|
||||
* @returns {number} 1 если добавлено, 0 если дубликат
|
||||
*/
|
||||
addMessage(message, optimistic = false) {
|
||||
if (!message || !message.id) {
|
||||
console.error('[MessageStore] Invalid message:', message);
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Проверяем дубликаты (тихо отклоняем - это нормальное поведение)
|
||||
if (this.messages.has(message.id)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Нормализуем сообщение: created_at → created_ts
|
||||
const normalizedMessage = { ...message };
|
||||
if (message.created_at && !message.created_ts) {
|
||||
normalizedMessage.created_ts = new Date(message.created_at).getTime();
|
||||
}
|
||||
|
||||
// Добавляем в основное хранилище
|
||||
this.messages.set(message.id, normalizedMessage);
|
||||
|
||||
// Добавляем в оптимистичное хранилище если нужно
|
||||
if (optimistic && message.temp_id) {
|
||||
this.optimisticMessages.set(message.temp_id, normalizedMessage);
|
||||
}
|
||||
|
||||
// Добавляем в список сообщений чата (сортированный)
|
||||
if (message.chat_id) {
|
||||
this._addToChatMessages(message.chat_id, message.id, normalizedMessage.created_ts);
|
||||
}
|
||||
|
||||
// Уведомляем слушателей
|
||||
this._notify('messageAdded', { message, optimistic });
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Добавляет множество сообщений (batch)
|
||||
* @param {Array<Message>} messages - Массив сообщений
|
||||
* @returns {number} Количество добавленных сообщений
|
||||
*/
|
||||
addMessages(messages) {
|
||||
if (!Array.isArray(messages)) {
|
||||
console.error('[MessageStore] Invalid messages array:', messages);
|
||||
return 0;
|
||||
}
|
||||
|
||||
console.log('[MessageStore] addMessages: adding', messages.length, 'messages');
|
||||
let addedCount = 0;
|
||||
messages.forEach(message => {
|
||||
if (this.addMessage(message, false)) {
|
||||
addedCount++;
|
||||
}
|
||||
});
|
||||
|
||||
console.log('[MessageStore] Batch added:', addedCount, 'of', messages.length, '| Total in store:', this.messages.size);
|
||||
|
||||
// Уведомляем о batch загрузке
|
||||
this._notify('messagesAdded', { messages, count: addedCount });
|
||||
|
||||
return addedCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* Обновляет существующее сообщение
|
||||
* @param {number|string} messageId - ID сообщения
|
||||
* @param {Partial<Message>} updates - Обновления
|
||||
* @returns {boolean} true если обновлено успешно
|
||||
*/
|
||||
updateMessage(messageId, updates) {
|
||||
const message = this.messages.get(messageId);
|
||||
if (!message) {
|
||||
console.warn('[MessageStore] Message not found for update:', messageId);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Применяем обновления
|
||||
const updated = { ...message, ...updates };
|
||||
this.messages.set(messageId, updated);
|
||||
|
||||
console.log('[MessageStore] Message updated:', messageId, updates);
|
||||
|
||||
// Уведомляем слушателей
|
||||
this._notify('messageUpdated', { messageId, message: updated, updates });
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Удаляет сообщение из Store
|
||||
* @param {number|string} messageId - ID сообщения
|
||||
* @returns {boolean} true если удалено успешно
|
||||
*/
|
||||
removeMessage(messageId) {
|
||||
const message = this.messages.get(messageId);
|
||||
if (!message) {
|
||||
console.warn('[MessageStore] Message not found for removal:', messageId);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Удаляем из основного хранилища
|
||||
this.messages.delete(messageId);
|
||||
|
||||
// Удаляем из списка чата
|
||||
if (message.chat_id) {
|
||||
const chatMsgIds = this.chatMessages.get(message.chat_id);
|
||||
if (chatMsgIds) {
|
||||
const index = chatMsgIds.indexOf(messageId);
|
||||
if (index > -1) {
|
||||
chatMsgIds.splice(index, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log('[MessageStore] Message removed:', messageId);
|
||||
|
||||
// Уведомляем слушателей
|
||||
this._notify('messageRemoved', { messageId, message });
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// ==================== Оптимистичные обновления ====================
|
||||
|
||||
/**
|
||||
* Добавляет оптимистичное сообщение (еще не подтверждено сервером)
|
||||
* @param {string} tempId - Временный ID сообщения
|
||||
* @param {Message} message - Сообщение с временным ID
|
||||
* @returns {number} 1 если добавлено, 0 если ошибка
|
||||
*/
|
||||
addOptimisticMessage(tempId, message) {
|
||||
if (!tempId || !message) {
|
||||
console.error('[MessageStore] Invalid optimistic message:', tempId, message);
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Добавляем как обычное сообщение с флагом optimistic
|
||||
const optimisticMsg = {
|
||||
...message,
|
||||
id: message.id || tempId,
|
||||
temp_id: tempId,
|
||||
is_optimistic: true,
|
||||
status: 'sending'
|
||||
};
|
||||
|
||||
// Сохраняем в optimisticMessages Map
|
||||
this.optimisticMessages.set(tempId, optimisticMsg);
|
||||
|
||||
// Добавляем в основное хранилище
|
||||
const result = this.addMessage(optimisticMsg, true);
|
||||
|
||||
console.log('[MessageStore] Optimistic message added:', tempId);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Подтверждает оптимистичное сообщение (пришел ответ от сервера)
|
||||
* @param {string} tempId - Временный ID
|
||||
* @param {Message} serverMessage - Сообщение с сервера
|
||||
* @returns {boolean} true если подтверждено успешно
|
||||
*/
|
||||
confirmOptimisticMessage(tempId, serverMessage) {
|
||||
const optimisticMsg = this.optimisticMessages.get(tempId);
|
||||
if (!optimisticMsg) {
|
||||
console.warn('[MessageStore] Optimistic message not found:', tempId);
|
||||
// Добавляем как обычное сообщение
|
||||
return this.addMessage(serverMessage, false);
|
||||
}
|
||||
|
||||
// Удаляем временное сообщение
|
||||
this.messages.delete(optimisticMsg.id);
|
||||
this.optimisticMessages.delete(tempId);
|
||||
|
||||
// Удаляем из списка чата
|
||||
if (optimisticMsg.chat_id) {
|
||||
const chatMsgIds = this.chatMessages.get(optimisticMsg.chat_id);
|
||||
if (chatMsgIds) {
|
||||
const index = chatMsgIds.indexOf(optimisticMsg.id);
|
||||
if (index > -1) {
|
||||
chatMsgIds.splice(index, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Добавляем подтвержденное сообщение
|
||||
this.addMessage(serverMessage, false);
|
||||
|
||||
console.log('[MessageStore] Optimistic message confirmed:', tempId, '→', serverMessage.id);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Помечает оптимистичное сообщение как failed
|
||||
* @param {string} tempId - Временный ID
|
||||
* @returns {boolean} true если обновлено успешно
|
||||
*/
|
||||
failOptimisticMessage(tempId) {
|
||||
const optimisticMsg = this.optimisticMessages.get(tempId);
|
||||
if (!optimisticMsg) {
|
||||
console.warn('[MessageStore] Optimistic message not found:', tempId);
|
||||
return false;
|
||||
}
|
||||
|
||||
this.updateMessage(optimisticMsg.id, { status: 'failed' });
|
||||
return true;
|
||||
}
|
||||
|
||||
// ==================== Геттеры ====================
|
||||
|
||||
/**
|
||||
* Получить сообщение по ID
|
||||
* @param {number|string} messageId - ID сообщения
|
||||
* @returns {Message|null}
|
||||
*/
|
||||
getMessage(messageId) {
|
||||
return this.messages.get(messageId) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Получить все сообщения для чата
|
||||
* @param {number} chatId - ID чата
|
||||
* @param {Object} options - Опции
|
||||
* @param {number} [options.limit] - Ограничение количества
|
||||
* @param {number|string} [options.beforeId] - Получить сообщения до этого ID
|
||||
* @param {number|string} [options.afterId] - Получить сообщения после этого ID
|
||||
* @returns {Array<Message>}
|
||||
*/
|
||||
getMessagesForChat(chatId, options = {}) {
|
||||
const messageIds = this.chatMessages.get(chatId) || [];
|
||||
let messages = messageIds.map(id => this.messages.get(id)).filter(Boolean);
|
||||
|
||||
// Фильтрация по beforeId
|
||||
if (options.beforeId) {
|
||||
const beforeIndex = messages.findIndex(m => m.id === options.beforeId);
|
||||
if (beforeIndex > -1) {
|
||||
messages = messages.slice(0, beforeIndex);
|
||||
}
|
||||
}
|
||||
|
||||
// Фильтрация по afterId
|
||||
if (options.afterId) {
|
||||
const afterIndex = messages.findIndex(m => m.id === options.afterId);
|
||||
if (afterIndex > -1) {
|
||||
messages = messages.slice(afterIndex + 1);
|
||||
}
|
||||
}
|
||||
|
||||
// Лимит
|
||||
if (options.limit) {
|
||||
messages = messages.slice(-options.limit);
|
||||
}
|
||||
|
||||
return messages;
|
||||
}
|
||||
|
||||
/**
|
||||
* Получить сообщения с day-dividers
|
||||
* @param {number} chatId - ID чата
|
||||
* @returns {Array<{type: 'divider'|'message', text?: string, message?: Message}>}
|
||||
*/
|
||||
getMessagesWithDividers(chatId) {
|
||||
const messages = this.getMessagesForChat(chatId);
|
||||
const result = [];
|
||||
let lastDay = null;
|
||||
|
||||
messages.forEach(message => {
|
||||
const msgDate = new Date(message.created_ts);
|
||||
const msgDay = this._formatDay(msgDate);
|
||||
|
||||
// Добавляем divider если день изменился
|
||||
if (msgDay !== lastDay) {
|
||||
result.push({
|
||||
type: 'day-divider',
|
||||
text: msgDay
|
||||
});
|
||||
lastDay = msgDay;
|
||||
}
|
||||
|
||||
// Добавляем сообщение
|
||||
result.push({
|
||||
type: 'message',
|
||||
message
|
||||
});
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Проверяет наличие сообщения
|
||||
* @param {number|string} messageId - ID сообщения
|
||||
* @returns {boolean}
|
||||
*/
|
||||
hasMessage(messageId) {
|
||||
return this.messages.has(messageId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Получить сообщение по ID
|
||||
* @param {number|string} messageId - ID сообщения
|
||||
* @returns {Message|null}
|
||||
*/
|
||||
getMessage(messageId) {
|
||||
return this.messages.get(messageId) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Получить самое старое сообщение в чате
|
||||
* @param {number} chatId - ID чата
|
||||
* @returns {Message|null}
|
||||
*/
|
||||
getOldestMessage(chatId) {
|
||||
const messageIds = this.chatMessages.get(chatId) || [];
|
||||
if (messageIds.length === 0) return null;
|
||||
|
||||
return this.messages.get(messageIds[0]) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Получить самое новое сообщение в чате
|
||||
* @param {number} chatId - ID чата
|
||||
* @returns {Message|null}
|
||||
*/
|
||||
getNewestMessage(chatId) {
|
||||
const messageIds = this.chatMessages.get(chatId) || [];
|
||||
if (messageIds.length === 0) return null;
|
||||
|
||||
return this.messages.get(messageIds[messageIds.length - 1]) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Получить количество сообщений в чате
|
||||
* @param {number} chatId - ID чата
|
||||
* @returns {number}
|
||||
*/
|
||||
getMessageCount(chatId) {
|
||||
const messageIds = this.chatMessages.get(chatId) || [];
|
||||
return messageIds.length;
|
||||
}
|
||||
|
||||
// ==================== Подписки ====================
|
||||
|
||||
/**
|
||||
* Подписаться на изменения Store
|
||||
* @param {Function} listener - Функция-слушатель (event, data) => void
|
||||
* @returns {Function} Функция отписки
|
||||
*/
|
||||
subscribe(listener) {
|
||||
if (typeof listener !== 'function') {
|
||||
console.error('[MessageStore] Listener must be a function');
|
||||
return () => {};
|
||||
}
|
||||
|
||||
this.listeners.add(listener);
|
||||
console.log('[MessageStore] Listener subscribed, total:', this.listeners.size);
|
||||
|
||||
// Возвращаем функцию отписки
|
||||
return () => this.unsubscribe(listener);
|
||||
}
|
||||
|
||||
/**
|
||||
* Отписаться от изменений Store
|
||||
* @param {Function} listener - Функция-слушатель
|
||||
*/
|
||||
unsubscribe(listener) {
|
||||
this.listeners.delete(listener);
|
||||
console.log('[MessageStore] Listener unsubscribed, remaining:', this.listeners.size);
|
||||
}
|
||||
|
||||
// ==================== Утилиты ====================
|
||||
|
||||
/**
|
||||
* Очистить все сообщения чата
|
||||
* @param {number} chatId - ID чата
|
||||
*/
|
||||
clearChat(chatId) {
|
||||
const messageIds = this.chatMessages.get(chatId) || [];
|
||||
messageIds.forEach(id => this.messages.delete(id));
|
||||
this.chatMessages.delete(chatId);
|
||||
|
||||
console.log('[MessageStore] Chat cleared:', chatId);
|
||||
this._notify('chatCleared', { chatId });
|
||||
}
|
||||
|
||||
/**
|
||||
* Получить статистику Store
|
||||
* @returns {Object}
|
||||
*/
|
||||
getStats() {
|
||||
return {
|
||||
totalMessages: this.messages.size,
|
||||
totalChats: this.chatMessages.size,
|
||||
optimisticMessages: this.optimisticMessages.size,
|
||||
listeners: this.listeners.size
|
||||
};
|
||||
}
|
||||
|
||||
// ==================== Приватные методы ====================
|
||||
|
||||
/**
|
||||
* Добавляет ID сообщения в отсортированный список чата
|
||||
* @private
|
||||
*/
|
||||
_addToChatMessages(chatId, messageId, timestamp) {
|
||||
if (!this.chatMessages.has(chatId)) {
|
||||
this.chatMessages.set(chatId, []);
|
||||
}
|
||||
|
||||
const chatMsgIds = this.chatMessages.get(chatId);
|
||||
|
||||
// Проверяем что messageId еще нет в индексе (защита от дублей)
|
||||
if (chatMsgIds.includes(messageId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Ищем правильную позицию для вставки
|
||||
let insertIndex = 0; // По умолчанию вставляем в начало
|
||||
|
||||
for (let i = 0; i < chatMsgIds.length; i++) {
|
||||
const existingMsg = this.messages.get(chatMsgIds[i]);
|
||||
if (existingMsg && existingMsg.created_ts < timestamp) {
|
||||
insertIndex = i + 1;
|
||||
} else {
|
||||
// Нашли первое сообщение с timestamp >= нового
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
chatMsgIds.splice(insertIndex, 0, messageId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Форматирует дату в текст дня
|
||||
* @private
|
||||
*/
|
||||
_formatDay(date) {
|
||||
const today = new Date();
|
||||
const yesterday = new Date(today);
|
||||
yesterday.setDate(yesterday.getDate() - 1);
|
||||
|
||||
const isToday = date.toDateString() === today.toDateString();
|
||||
const isYesterday = date.toDateString() === yesterday.toDateString();
|
||||
|
||||
if (isToday) return 'Сегодня';
|
||||
if (isYesterday) return 'Вчера';
|
||||
|
||||
const options = { day: 'numeric', month: 'long', year: 'numeric' };
|
||||
return date.toLocaleDateString('ru-RU', options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Уведомляет всех слушателей об изменении
|
||||
* @private
|
||||
*/
|
||||
_notify(event, data) {
|
||||
this.listeners.forEach(listener => {
|
||||
try {
|
||||
listener(event, data);
|
||||
} catch (error) {
|
||||
console.error('[MessageStore] Listener error:', error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ==================== Event Listeners ====================
|
||||
|
||||
/**
|
||||
* Добавляет слушателя событий
|
||||
* @param {string} event - Название события (или '*' для всех)
|
||||
* @param {Function} callback - Функция-обработчик
|
||||
*/
|
||||
on(event, callback) {
|
||||
if (typeof callback !== 'function') {
|
||||
console.error('[MessageStore] Invalid callback for event:', event);
|
||||
return;
|
||||
}
|
||||
|
||||
// Оборачиваем callback чтобы фильтровать по event
|
||||
const wrappedCallback = (eventName, data) => {
|
||||
if (event === '*' || event === eventName) {
|
||||
callback(data);
|
||||
}
|
||||
};
|
||||
|
||||
// Сохраняем связь для off()
|
||||
wrappedCallback._original = callback;
|
||||
wrappedCallback._event = event;
|
||||
|
||||
this.listeners.add(wrappedCallback);
|
||||
}
|
||||
|
||||
/**
|
||||
* Удаляет слушателя событий
|
||||
* @param {string} event - Название события
|
||||
* @param {Function} callback - Функция-обработчик
|
||||
*/
|
||||
off(event, callback) {
|
||||
// Ищем обернутый callback
|
||||
for (const listener of this.listeners) {
|
||||
if (listener._original === callback && listener._event === event) {
|
||||
this.listeners.delete(listener);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Удаляет всех слушателей
|
||||
*/
|
||||
offAll() {
|
||||
this.listeners.clear();
|
||||
}
|
||||
}
|
||||
|
||||
export default MessageStore;
|
||||
303
backend/static/js/stores/messageStore.test.js
Normal file
303
backend/static/js/stores/messageStore.test.js
Normal file
@ -0,0 +1,303 @@
|
||||
/**
|
||||
* @fileoverview Тесты для MessageStore
|
||||
* Простые тесты для проверки основной функциональности Store
|
||||
*/
|
||||
|
||||
import { MessageStore } from '../stores/messageStore.js';
|
||||
|
||||
/**
|
||||
* Вспомогательная функция для создания тестового сообщения
|
||||
*/
|
||||
function createTestMessage(id, chatId, content, timestamp = Date.now()) {
|
||||
return {
|
||||
id,
|
||||
chat_id: chatId,
|
||||
author_id: 1,
|
||||
content,
|
||||
created_ts: timestamp,
|
||||
is_edited: false,
|
||||
reactions_summary: {}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Простой тест-раннер
|
||||
*/
|
||||
function runTests() {
|
||||
console.log('🧪 Running MessageStore tests...\n');
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
// Test 1: Создание Store
|
||||
try {
|
||||
const store = new MessageStore({ currentUserId: 1 });
|
||||
console.assert(store.messages.size === 0, 'Store should start empty');
|
||||
console.log('✅ Test 1: Store creation - PASSED');
|
||||
passed++;
|
||||
} catch (error) {
|
||||
console.error('❌ Test 1: Store creation - FAILED:', error);
|
||||
failed++;
|
||||
}
|
||||
|
||||
// Test 2: Добавление сообщения
|
||||
try {
|
||||
const store = new MessageStore({ currentUserId: 1 });
|
||||
const msg = createTestMessage(1, 100, 'Test message');
|
||||
const added = store.addMessage(msg);
|
||||
|
||||
console.assert(added === true, 'addMessage should return true');
|
||||
console.assert(store.messages.size === 1, 'Store should have 1 message');
|
||||
console.assert(store.getMessage(1) !== null, 'getMessage should return the message');
|
||||
console.log('✅ Test 2: Add message - PASSED');
|
||||
passed++;
|
||||
} catch (error) {
|
||||
console.error('❌ Test 2: Add message - FAILED:', error);
|
||||
failed++;
|
||||
}
|
||||
|
||||
// Test 3: Предотвращение дубликатов
|
||||
try {
|
||||
const store = new MessageStore({ currentUserId: 1 });
|
||||
const msg = createTestMessage(1, 100, 'Test message');
|
||||
|
||||
store.addMessage(msg);
|
||||
const duplicate = store.addMessage(msg);
|
||||
|
||||
console.assert(duplicate === false, 'Adding duplicate should return false');
|
||||
console.assert(store.messages.size === 1, 'Store should still have 1 message');
|
||||
console.log('✅ Test 3: Duplicate prevention - PASSED');
|
||||
passed++;
|
||||
} catch (error) {
|
||||
console.error('❌ Test 3: Duplicate prevention - FAILED:', error);
|
||||
failed++;
|
||||
}
|
||||
|
||||
// Test 4: Batch добавление
|
||||
try {
|
||||
const store = new MessageStore({ currentUserId: 1 });
|
||||
const messages = [
|
||||
createTestMessage(1, 100, 'Message 1', 1000),
|
||||
createTestMessage(2, 100, 'Message 2', 2000),
|
||||
createTestMessage(3, 100, 'Message 3', 3000)
|
||||
];
|
||||
|
||||
const count = store.addMessages(messages);
|
||||
|
||||
console.assert(count === 3, 'addMessages should return count of 3');
|
||||
console.assert(store.messages.size === 3, 'Store should have 3 messages');
|
||||
console.log('✅ Test 4: Batch add - PASSED');
|
||||
passed++;
|
||||
} catch (error) {
|
||||
console.error('❌ Test 4: Batch add - FAILED:', error);
|
||||
failed++;
|
||||
}
|
||||
|
||||
// Test 5: Обновление сообщения
|
||||
try {
|
||||
const store = new MessageStore({ currentUserId: 1 });
|
||||
const msg = createTestMessage(1, 100, 'Original');
|
||||
|
||||
store.addMessage(msg);
|
||||
const updated = store.updateMessage(1, { content: 'Updated', is_edited: true });
|
||||
|
||||
console.assert(updated === true, 'updateMessage should return true');
|
||||
|
||||
const retrieved = store.getMessage(1);
|
||||
console.assert(retrieved.content === 'Updated', 'Content should be updated');
|
||||
console.assert(retrieved.is_edited === true, 'is_edited should be true');
|
||||
console.log('✅ Test 5: Update message - PASSED');
|
||||
passed++;
|
||||
} catch (error) {
|
||||
console.error('❌ Test 5: Update message - FAILED:', error);
|
||||
failed++;
|
||||
}
|
||||
|
||||
// Test 6: Удаление сообщения
|
||||
try {
|
||||
const store = new MessageStore({ currentUserId: 1 });
|
||||
const msg = createTestMessage(1, 100, 'To be deleted');
|
||||
|
||||
store.addMessage(msg);
|
||||
const removed = store.removeMessage(1);
|
||||
|
||||
console.assert(removed === true, 'removeMessage should return true');
|
||||
console.assert(store.messages.size === 0, 'Store should be empty');
|
||||
console.assert(store.getMessage(1) === null, 'getMessage should return null');
|
||||
console.log('✅ Test 6: Remove message - PASSED');
|
||||
passed++;
|
||||
} catch (error) {
|
||||
console.error('❌ Test 6: Remove message - FAILED:', error);
|
||||
failed++;
|
||||
}
|
||||
|
||||
// Test 7: Получение сообщений чата
|
||||
try {
|
||||
const store = new MessageStore({ currentUserId: 1 });
|
||||
const messages = [
|
||||
createTestMessage(1, 100, 'Chat 100 msg 1'),
|
||||
createTestMessage(2, 100, 'Chat 100 msg 2'),
|
||||
createTestMessage(3, 200, 'Chat 200 msg 1')
|
||||
];
|
||||
|
||||
store.addMessages(messages);
|
||||
|
||||
const chat100 = store.getMessagesForChat(100);
|
||||
const chat200 = store.getMessagesForChat(200);
|
||||
|
||||
console.assert(chat100.length === 2, 'Chat 100 should have 2 messages');
|
||||
console.assert(chat200.length === 1, 'Chat 200 should have 1 message');
|
||||
console.log('✅ Test 7: Get messages for chat - PASSED');
|
||||
passed++;
|
||||
} catch (error) {
|
||||
console.error('❌ Test 7: Get messages for chat - FAILED:', error);
|
||||
failed++;
|
||||
}
|
||||
|
||||
// Test 8: Сортировка по timestamp
|
||||
try {
|
||||
const store = new MessageStore({ currentUserId: 1 });
|
||||
const messages = [
|
||||
createTestMessage(3, 100, 'Third', 3000),
|
||||
createTestMessage(1, 100, 'First', 1000),
|
||||
createTestMessage(2, 100, 'Second', 2000)
|
||||
];
|
||||
|
||||
store.addMessages(messages);
|
||||
const chat = store.getMessagesForChat(100);
|
||||
|
||||
console.assert(chat[0].id === 1, 'First message should have id 1');
|
||||
console.assert(chat[1].id === 2, 'Second message should have id 2');
|
||||
console.assert(chat[2].id === 3, 'Third message should have id 3');
|
||||
console.log('✅ Test 8: Message sorting - PASSED');
|
||||
passed++;
|
||||
} catch (error) {
|
||||
console.error('❌ Test 8: Message sorting - FAILED:', error);
|
||||
failed++;
|
||||
}
|
||||
|
||||
// Test 9: Day-dividers
|
||||
try {
|
||||
const store = new MessageStore({ currentUserId: 1 });
|
||||
const today = new Date();
|
||||
const yesterday = new Date(today);
|
||||
yesterday.setDate(yesterday.getDate() - 1);
|
||||
|
||||
const messages = [
|
||||
createTestMessage(1, 100, 'Yesterday msg', yesterday.getTime()),
|
||||
createTestMessage(2, 100, 'Today msg 1', today.getTime()),
|
||||
createTestMessage(3, 100, 'Today msg 2', today.getTime() + 1000)
|
||||
];
|
||||
|
||||
store.addMessages(messages);
|
||||
const items = store.getMessagesWithDividers(100);
|
||||
|
||||
// Должно быть: divider "Вчера" + msg1 + divider "Сегодня" + msg2 + msg3
|
||||
console.assert(items.length === 5, 'Should have 5 items (2 dividers + 3 messages)');
|
||||
console.assert(items[0].type === 'divider', 'First should be divider');
|
||||
console.assert(items[1].type === 'message', 'Second should be message');
|
||||
console.assert(items[2].type === 'divider', 'Third should be divider');
|
||||
console.log('✅ Test 9: Day-dividers - PASSED');
|
||||
passed++;
|
||||
} catch (error) {
|
||||
console.error('❌ Test 9: Day-dividers - FAILED:', error);
|
||||
failed++;
|
||||
}
|
||||
|
||||
// Test 10: Подписки (subscribe/unsubscribe)
|
||||
try {
|
||||
const store = new MessageStore({ currentUserId: 1 });
|
||||
let eventReceived = null;
|
||||
|
||||
const unsubscribe = store.subscribe((event, data) => {
|
||||
eventReceived = event;
|
||||
});
|
||||
|
||||
store.addMessage(createTestMessage(1, 100, 'Test'));
|
||||
|
||||
console.assert(eventReceived === 'message_added', 'Should receive message_added event');
|
||||
|
||||
eventReceived = null;
|
||||
unsubscribe();
|
||||
|
||||
store.addMessage(createTestMessage(2, 100, 'Test 2'));
|
||||
console.assert(eventReceived === null, 'Should not receive event after unsubscribe');
|
||||
|
||||
console.log('✅ Test 10: Subscriptions - PASSED');
|
||||
passed++;
|
||||
} catch (error) {
|
||||
console.error('❌ Test 10: Subscriptions - FAILED:', error);
|
||||
failed++;
|
||||
}
|
||||
|
||||
// Test 11: Оптимистичные сообщения
|
||||
try {
|
||||
const store = new MessageStore({ currentUserId: 1 });
|
||||
const tempId = 'temp_123';
|
||||
const optimisticMsg = {
|
||||
...createTestMessage('temp_123', 100, 'Sending...'),
|
||||
temp_id: tempId,
|
||||
status: 'sending'
|
||||
};
|
||||
|
||||
store.addMessage(optimisticMsg, true);
|
||||
console.assert(store.optimisticMessages.has(tempId), 'Should have optimistic message');
|
||||
|
||||
const serverMsg = createTestMessage(1, 100, 'Sending...');
|
||||
serverMsg.temp_id = tempId;
|
||||
|
||||
store.confirmOptimisticMessage(tempId, serverMsg);
|
||||
|
||||
console.assert(!store.optimisticMessages.has(tempId), 'Should remove optimistic message');
|
||||
console.assert(store.getMessage(1) !== null, 'Should have confirmed message');
|
||||
console.assert(store.getMessage('temp_123') === null, 'Should remove temp message');
|
||||
|
||||
console.log('✅ Test 11: Optimistic messages - PASSED');
|
||||
passed++;
|
||||
} catch (error) {
|
||||
console.error('❌ Test 11: Optimistic messages - FAILED:', error);
|
||||
failed++;
|
||||
}
|
||||
|
||||
// Test 12: getOldestMessage / getNewestMessage
|
||||
try {
|
||||
const store = new MessageStore({ currentUserId: 1 });
|
||||
const messages = [
|
||||
createTestMessage(1, 100, 'First', 1000),
|
||||
createTestMessage(2, 100, 'Second', 2000),
|
||||
createTestMessage(3, 100, 'Third', 3000)
|
||||
];
|
||||
|
||||
store.addMessages(messages);
|
||||
|
||||
const oldest = store.getOldestMessage(100);
|
||||
const newest = store.getNewestMessage(100);
|
||||
|
||||
console.assert(oldest.id === 1, 'Oldest should be message 1');
|
||||
console.assert(newest.id === 3, 'Newest should be message 3');
|
||||
|
||||
console.log('✅ Test 12: Oldest/Newest message - PASSED');
|
||||
passed++;
|
||||
} catch (error) {
|
||||
console.error('❌ Test 12: Oldest/Newest message - FAILED:', error);
|
||||
failed++;
|
||||
}
|
||||
|
||||
// Итоги
|
||||
console.log('\n' + '='.repeat(50));
|
||||
console.log(`✅ Passed: ${passed}`);
|
||||
console.log(`❌ Failed: ${failed}`);
|
||||
console.log(`📊 Total: ${passed + failed}`);
|
||||
console.log('='.repeat(50));
|
||||
|
||||
return { passed, failed };
|
||||
}
|
||||
|
||||
// Экспортируем для использования
|
||||
export { runTests, createTestMessage };
|
||||
|
||||
// Автозапуск если файл загружен напрямую
|
||||
if (typeof window !== 'undefined') {
|
||||
window.runMessageStoreTests = runTests;
|
||||
console.log('💡 Тесты загружены! Запустите: runMessageStoreTests()');
|
||||
}
|
||||
536
backend/static/js/stores/messageStoreV2.js
Normal file
536
backend/static/js/stores/messageStoreV2.js
Normal file
@ -0,0 +1,536 @@
|
||||
/**
|
||||
* @fileoverview MessageStore V2 - оптимизированное хранилище сообщений
|
||||
* @module stores/messageStoreV2
|
||||
*
|
||||
* РЕФАКТОРИНГ:
|
||||
* - Бинарный поиск для вставки в отсортированный список
|
||||
* - Batch операции с отложенными уведомлениями
|
||||
* - WeakMap для кэширования
|
||||
* - Оптимизированные геттеры
|
||||
* - Меньше логирования
|
||||
*/
|
||||
|
||||
import { STORE_EVENTS } from '../config/chatConfig.js';
|
||||
|
||||
/**
|
||||
* @typedef {Object} Message
|
||||
* @property {number|string} id - ID сообщения
|
||||
* @property {number} chat_id - ID чата
|
||||
* @property {number} author_id - ID автора
|
||||
* @property {string} content - Текст сообщения
|
||||
* @property {number} created_ts - Timestamp создания (мс)
|
||||
* @property {boolean} [is_edited] - Редактировано ли
|
||||
* @property {number} [edited_at] - Timestamp редактирования
|
||||
* @property {Object} [reactions_summary] - Реакции
|
||||
* @property {string} [status] - Статус: 'sending' | 'sent' | 'failed'
|
||||
* @property {string} [temp_id] - Временный ID для оптимистичных сообщений
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} DividerItem
|
||||
* @property {'divider'} type
|
||||
* @property {string} text
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} MessageItem
|
||||
* @property {'message'} type
|
||||
* @property {Message} message
|
||||
*/
|
||||
|
||||
/**
|
||||
* MessageStore V2 - централизованное хранилище сообщений
|
||||
* Реализует паттерн Single Source of Truth.
|
||||
*/
|
||||
export class MessageStoreV2 {
|
||||
/**
|
||||
* @param {Object} options
|
||||
* @param {number} options.currentUserId - ID текущего пользователя
|
||||
*/
|
||||
constructor(options = {}) {
|
||||
/** @type {Map<number|string, Message>} Основное хранилище */
|
||||
this.messages = new Map();
|
||||
|
||||
/** @type {Map<number, Array<number|string>>} Отсортированные ID по чатам */
|
||||
this.chatIndex = new Map();
|
||||
|
||||
/** @type {Map<string, Message>} Оптимистичные сообщения */
|
||||
this.optimisticMessages = new Map();
|
||||
|
||||
/** @type {Set<Function>} Подписчики */
|
||||
this.listeners = new Set();
|
||||
|
||||
/** @type {number} */
|
||||
this.currentUserId = options.currentUserId;
|
||||
|
||||
// Batch mode
|
||||
this._batchMode = false;
|
||||
this._pendingNotifications = [];
|
||||
|
||||
console.log('[MessageStoreV2] Initialized');
|
||||
}
|
||||
|
||||
// ==================== CRUD операции ====================
|
||||
|
||||
/**
|
||||
* Добавляет сообщение в Store
|
||||
* @param {Message} message - Сообщение
|
||||
* @param {boolean} [optimistic=false] - Оптимистичное ли
|
||||
* @returns {boolean} true если добавлено
|
||||
*/
|
||||
addMessage(message, optimistic = false) {
|
||||
if (!this._validateMessage(message)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Проверка дубликата
|
||||
if (this.messages.has(message.id)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Сохраняем копию
|
||||
const msgCopy = { ...message };
|
||||
this.messages.set(message.id, msgCopy);
|
||||
|
||||
// Оптимистичные
|
||||
if (optimistic && message.temp_id) {
|
||||
this.optimisticMessages.set(message.temp_id, msgCopy);
|
||||
}
|
||||
|
||||
// Индексируем по чату
|
||||
if (message.chat_id) {
|
||||
this._addToIndex(message.chat_id, message.id, message.created_ts);
|
||||
}
|
||||
|
||||
// Уведомляем
|
||||
this._notify(STORE_EVENTS.MESSAGE_ADDED, { message: msgCopy, optimistic });
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Добавляет множество сообщений (batch)
|
||||
* @param {Array<Message>} messages - Сообщения
|
||||
* @returns {number} Количество добавленных
|
||||
*/
|
||||
addMessages(messages) {
|
||||
if (!Array.isArray(messages) || messages.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Включаем batch mode
|
||||
this._batchMode = true;
|
||||
let addedCount = 0;
|
||||
|
||||
try {
|
||||
for (const message of messages) {
|
||||
if (this.addMessage(message, false)) {
|
||||
addedCount++;
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
// Выключаем batch mode и отправляем все уведомления
|
||||
this._batchMode = false;
|
||||
this._flushNotifications();
|
||||
}
|
||||
|
||||
// Отправляем групповое уведомление
|
||||
this._notify(STORE_EVENTS.MESSAGES_LOADED, { count: addedCount });
|
||||
|
||||
return addedCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* Обновляет сообщение
|
||||
* @param {number|string} messageId - ID сообщения
|
||||
* @param {Partial<Message>} updates - Обновления
|
||||
* @returns {boolean}
|
||||
*/
|
||||
updateMessage(messageId, updates) {
|
||||
const message = this.messages.get(messageId);
|
||||
if (!message) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const updated = { ...message, ...updates };
|
||||
this.messages.set(messageId, updated);
|
||||
|
||||
this._notify(STORE_EVENTS.MESSAGE_UPDATED, {
|
||||
messageId,
|
||||
message: updated,
|
||||
updates
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Удаляет сообщение
|
||||
* @param {number|string} messageId - ID сообщения
|
||||
* @returns {boolean}
|
||||
*/
|
||||
removeMessage(messageId) {
|
||||
const message = this.messages.get(messageId);
|
||||
if (!message) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this.messages.delete(messageId);
|
||||
|
||||
// Удаляем из индекса
|
||||
if (message.chat_id) {
|
||||
this._removeFromIndex(message.chat_id, messageId);
|
||||
}
|
||||
|
||||
this._notify(STORE_EVENTS.MESSAGE_REMOVED, { messageId, message });
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// ==================== Оптимистичные обновления ====================
|
||||
|
||||
/**
|
||||
* Подтверждает оптимистичное сообщение
|
||||
* @param {string} tempId - Временный ID
|
||||
* @param {Message} serverMessage - Сообщение с сервера
|
||||
* @returns {boolean}
|
||||
*/
|
||||
confirmOptimisticMessage(tempId, serverMessage) {
|
||||
const optimisticMsg = this.optimisticMessages.get(tempId);
|
||||
|
||||
if (!optimisticMsg) {
|
||||
// Просто добавляем как обычное
|
||||
return this.addMessage(serverMessage, false);
|
||||
}
|
||||
|
||||
// Удаляем оптимистичное
|
||||
this.messages.delete(optimisticMsg.id);
|
||||
this.optimisticMessages.delete(tempId);
|
||||
|
||||
if (optimisticMsg.chat_id) {
|
||||
this._removeFromIndex(optimisticMsg.chat_id, optimisticMsg.id);
|
||||
}
|
||||
|
||||
// Добавляем подтвержденное
|
||||
this.addMessage(serverMessage, false);
|
||||
|
||||
this._notify(STORE_EVENTS.OPTIMISTIC_CONFIRMED, {
|
||||
tempId,
|
||||
oldMessage: optimisticMsg,
|
||||
newMessage: serverMessage
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Помечает оптимистичное сообщение как failed
|
||||
* @param {string} tempId - Временный ID
|
||||
* @returns {boolean}
|
||||
*/
|
||||
failOptimisticMessage(tempId) {
|
||||
const optimisticMsg = this.optimisticMessages.get(tempId);
|
||||
if (!optimisticMsg) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this.updateMessage(optimisticMsg.id, { status: 'failed' });
|
||||
|
||||
this._notify(STORE_EVENTS.OPTIMISTIC_FAILED, { tempId, message: optimisticMsg });
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// ==================== Геттеры ====================
|
||||
|
||||
/**
|
||||
* Получает сообщение по ID
|
||||
* @param {number|string} messageId
|
||||
* @returns {Message|null}
|
||||
*/
|
||||
getMessage(messageId) {
|
||||
return this.messages.get(messageId) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Проверяет наличие сообщения
|
||||
* @param {number|string} messageId
|
||||
* @returns {boolean}
|
||||
*/
|
||||
hasMessage(messageId) {
|
||||
return this.messages.has(messageId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Получает все сообщения чата
|
||||
* @param {number} chatId
|
||||
* @param {Object} [options]
|
||||
* @param {number} [options.limit]
|
||||
* @param {number|string} [options.beforeId]
|
||||
* @param {number|string} [options.afterId]
|
||||
* @returns {Array<Message>}
|
||||
*/
|
||||
getMessagesForChat(chatId, options = {}) {
|
||||
const messageIds = this.chatIndex.get(chatId) || [];
|
||||
let messages = messageIds
|
||||
.map(id => this.messages.get(id))
|
||||
.filter(Boolean);
|
||||
|
||||
// Фильтрация по beforeId
|
||||
if (options.beforeId) {
|
||||
const idx = messages.findIndex(m => m.id === options.beforeId);
|
||||
if (idx > -1) {
|
||||
messages = messages.slice(0, idx);
|
||||
}
|
||||
}
|
||||
|
||||
// Фильтрация по afterId
|
||||
if (options.afterId) {
|
||||
const idx = messages.findIndex(m => m.id === options.afterId);
|
||||
if (idx > -1) {
|
||||
messages = messages.slice(idx + 1);
|
||||
}
|
||||
}
|
||||
|
||||
// Лимит
|
||||
if (options.limit) {
|
||||
messages = messages.slice(-options.limit);
|
||||
}
|
||||
|
||||
return messages;
|
||||
}
|
||||
|
||||
/**
|
||||
* Получает сообщения с day-dividers
|
||||
* @param {number} chatId
|
||||
* @returns {Array<DividerItem|MessageItem>}
|
||||
*/
|
||||
getMessagesWithDividers(chatId) {
|
||||
const messages = this.getMessagesForChat(chatId);
|
||||
const result = [];
|
||||
let lastDay = null;
|
||||
|
||||
for (const message of messages) {
|
||||
const day = this._formatDay(new Date(message.created_ts));
|
||||
|
||||
if (day !== lastDay) {
|
||||
result.push({ type: 'divider', text: day });
|
||||
lastDay = day;
|
||||
}
|
||||
|
||||
result.push({ type: 'message', message });
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Получает самое старое сообщение чата
|
||||
* @param {number} chatId
|
||||
* @returns {Message|null}
|
||||
*/
|
||||
getOldestMessage(chatId) {
|
||||
const ids = this.chatIndex.get(chatId);
|
||||
if (!ids || ids.length === 0) return null;
|
||||
return this.messages.get(ids[0]) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Получает самое новое сообщение чата
|
||||
* @param {number} chatId
|
||||
* @returns {Message|null}
|
||||
*/
|
||||
getNewestMessage(chatId) {
|
||||
const ids = this.chatIndex.get(chatId);
|
||||
if (!ids || ids.length === 0) return null;
|
||||
return this.messages.get(ids[ids.length - 1]) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Получает количество сообщений в чате
|
||||
* @param {number} chatId
|
||||
* @returns {number}
|
||||
*/
|
||||
getMessageCount(chatId) {
|
||||
return (this.chatIndex.get(chatId) || []).length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Получает статистику Store
|
||||
* @returns {Object}
|
||||
*/
|
||||
getStats() {
|
||||
return {
|
||||
totalMessages: this.messages.size,
|
||||
totalChats: this.chatIndex.size,
|
||||
optimisticMessages: this.optimisticMessages.size,
|
||||
listeners: this.listeners.size
|
||||
};
|
||||
}
|
||||
|
||||
// ==================== Подписки ====================
|
||||
|
||||
/**
|
||||
* Подписаться на изменения
|
||||
* @param {Function} listener - (event, data) => void
|
||||
* @returns {Function} Функция отписки
|
||||
*/
|
||||
subscribe(listener) {
|
||||
if (typeof listener !== 'function') {
|
||||
throw new Error('[MessageStoreV2] Listener must be a function');
|
||||
}
|
||||
|
||||
this.listeners.add(listener);
|
||||
return () => this.unsubscribe(listener);
|
||||
}
|
||||
|
||||
/**
|
||||
* Отписаться
|
||||
* @param {Function} listener
|
||||
*/
|
||||
unsubscribe(listener) {
|
||||
this.listeners.delete(listener);
|
||||
}
|
||||
|
||||
// ==================== Управление ====================
|
||||
|
||||
/**
|
||||
* Очищает сообщения чата
|
||||
* @param {number} chatId
|
||||
*/
|
||||
clearChat(chatId) {
|
||||
const ids = this.chatIndex.get(chatId) || [];
|
||||
|
||||
for (const id of ids) {
|
||||
this.messages.delete(id);
|
||||
}
|
||||
|
||||
this.chatIndex.delete(chatId);
|
||||
|
||||
this._notify(STORE_EVENTS.CHAT_CLEARED, { chatId });
|
||||
}
|
||||
|
||||
/**
|
||||
* Полная очистка Store
|
||||
*/
|
||||
clear() {
|
||||
this.messages.clear();
|
||||
this.chatIndex.clear();
|
||||
this.optimisticMessages.clear();
|
||||
}
|
||||
|
||||
// ==================== Приватные методы ====================
|
||||
|
||||
/**
|
||||
* Валидирует сообщение
|
||||
* @private
|
||||
*/
|
||||
_validateMessage(message) {
|
||||
if (!message || !message.id) {
|
||||
console.error('[MessageStoreV2] Invalid message:', message);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Добавляет в индекс с бинарным поиском
|
||||
* @private
|
||||
*/
|
||||
_addToIndex(chatId, messageId, timestamp) {
|
||||
if (!this.chatIndex.has(chatId)) {
|
||||
this.chatIndex.set(chatId, []);
|
||||
}
|
||||
|
||||
const ids = this.chatIndex.get(chatId);
|
||||
|
||||
// Проверка дубликата в индексе
|
||||
if (ids.includes(messageId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Бинарный поиск для правильной позиции
|
||||
let left = 0;
|
||||
let right = ids.length;
|
||||
|
||||
while (left < right) {
|
||||
const mid = (left + right) >>> 1;
|
||||
const midMsg = this.messages.get(ids[mid]);
|
||||
|
||||
if (midMsg && midMsg.created_ts <= timestamp) {
|
||||
left = mid + 1;
|
||||
} else {
|
||||
right = mid;
|
||||
}
|
||||
}
|
||||
|
||||
ids.splice(left, 0, messageId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Удаляет из индекса
|
||||
* @private
|
||||
*/
|
||||
_removeFromIndex(chatId, messageId) {
|
||||
const ids = this.chatIndex.get(chatId);
|
||||
if (!ids) return;
|
||||
|
||||
const idx = ids.indexOf(messageId);
|
||||
if (idx > -1) {
|
||||
ids.splice(idx, 1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Форматирует дату в текст дня
|
||||
* @private
|
||||
*/
|
||||
_formatDay(date) {
|
||||
const today = new Date();
|
||||
const yesterday = new Date(today);
|
||||
yesterday.setDate(yesterday.getDate() - 1);
|
||||
|
||||
if (date.toDateString() === today.toDateString()) return 'Сегодня';
|
||||
if (date.toDateString() === yesterday.toDateString()) return 'Вчера';
|
||||
|
||||
return date.toLocaleDateString('ru-RU', {
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
year: 'numeric'
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Уведомляет подписчиков
|
||||
* @private
|
||||
*/
|
||||
_notify(event, data) {
|
||||
if (this._batchMode) {
|
||||
this._pendingNotifications.push({ event, data });
|
||||
return;
|
||||
}
|
||||
|
||||
for (const listener of this.listeners) {
|
||||
try {
|
||||
listener(event, data);
|
||||
} catch (error) {
|
||||
console.error('[MessageStoreV2] Listener error:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Отправляет накопленные уведомления
|
||||
* @private
|
||||
*/
|
||||
_flushNotifications() {
|
||||
const notifications = this._pendingNotifications;
|
||||
this._pendingNotifications = [];
|
||||
|
||||
for (const { event, data } of notifications) {
|
||||
this._notify(event, data);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default MessageStoreV2;
|
||||
99
backend/static/js/tests/README.md
Normal file
99
backend/static/js/tests/README.md
Normal file
@ -0,0 +1,99 @@
|
||||
# 🧪 Chat System Tests
|
||||
|
||||
Комплексная система тестирования для чата EUSRR. Запускается прямо в браузере без дополнительных инструментов.
|
||||
|
||||
## ⚡ Быстрый старт
|
||||
|
||||
### Вариант 1: HTML интерфейс (Рекомендуется)
|
||||
|
||||
Откройте в браузере:
|
||||
```
|
||||
http://localhost:8000/static/js/tests/runTests.html
|
||||
```
|
||||
|
||||
Нажмите кнопку "🚀 Запустить все тесты"
|
||||
|
||||
### Вариант 2: Консоль браузера
|
||||
|
||||
На странице чата откройте консоль (F12) и выполните:
|
||||
|
||||
```javascript
|
||||
// Загрузить тесты
|
||||
const script = document.createElement('script');
|
||||
script.src = '/static/js/tests/chatTests.js';
|
||||
script.type = 'module';
|
||||
document.head.appendChild(script);
|
||||
|
||||
// Запустить все тесты
|
||||
await window.ChatTests.runAll()
|
||||
|
||||
// Или отдельный модуль
|
||||
await window.ChatTests.testMessageStore()
|
||||
```
|
||||
|
||||
## 📦 Доступные тесты
|
||||
|
||||
```javascript
|
||||
window.ChatTests.runAll() // Все тесты
|
||||
window.ChatTests.testMessageStore() // Хранилище сообщений
|
||||
window.ChatTests.testMessageLoader() // Загрузчик сообщений
|
||||
window.ChatTests.testScrollManager() // Менеджер скролла
|
||||
window.ChatTests.testMessageRenderer() // Рендерер сообщений
|
||||
window.ChatTests.testChatController() // Контроллер чата
|
||||
window.ChatTests.testIntegration() // Интеграционные тесты
|
||||
window.ChatTests.testPerformance() // Тесты производительности
|
||||
```
|
||||
|
||||
## 📊 Что тестируется?
|
||||
|
||||
- ✅ **MessageStore** - 12 тестов (добавление, обновление, удаление, дубликаты, оптимистичные)
|
||||
- ✅ **MessageLoader** - 9 тестов (загрузка, WebSocket, состояния)
|
||||
- ✅ **ScrollManager** - 5 тестов (скролл, сохранение позиции, IntersectionObserver)
|
||||
- ✅ **MessageRenderer** - 6 тестов (рендеринг, обновление, удаление)
|
||||
- ✅ **ChatController** - 5 тестов (инициализация, методы, события)
|
||||
- ✅ **Integration** - 4 теста (полный цикл, множественные сообщения, оптимистичная отправка)
|
||||
- ✅ **Performance** - 4 теста (1000 сообщений, поиск, рендеринг, обновления)
|
||||
|
||||
**Всего: ~45+ тестов**
|
||||
|
||||
## 📈 Пример результата
|
||||
|
||||
```
|
||||
🏁 ИТОГОВЫЕ РЕЗУЛЬТАТЫ
|
||||
============================================================
|
||||
Всего тестов: 45
|
||||
✅ Успешно: 45
|
||||
❌ Провалено: 0
|
||||
📈 Процент успеха: 100.0%
|
||||
============================================================
|
||||
```
|
||||
|
||||
## 📚 Полная документация
|
||||
|
||||
См. [CHAT_TESTS_GUIDE.md](./CHAT_TESTS_GUIDE.md)
|
||||
|
||||
## 🎯 Использование
|
||||
|
||||
### Перед коммитом
|
||||
```javascript
|
||||
const results = await window.ChatTests.runAll()
|
||||
console.log(`Ready to commit: ${results.failed === 0}`)
|
||||
```
|
||||
|
||||
### После изменений
|
||||
```javascript
|
||||
// Изменили MessageStore?
|
||||
await window.ChatTests.testMessageStore()
|
||||
|
||||
// Изменили рендеринг?
|
||||
await window.ChatTests.testMessageRenderer()
|
||||
```
|
||||
|
||||
### Проверка производительности
|
||||
```javascript
|
||||
await window.ChatTests.testPerformance()
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**Документация:** [CHAT_TESTS_GUIDE.md](./CHAT_TESTS_GUIDE.md)
|
||||
4423
backend/static/js/tests/chat-styles-audit-1768224034043.json
Normal file
4423
backend/static/js/tests/chat-styles-audit-1768224034043.json
Normal file
File diff suppressed because it is too large
Load Diff
495
backend/static/js/tests/chatStylesAudit.js
Normal file
495
backend/static/js/tests/chatStylesAudit.js
Normal file
@ -0,0 +1,495 @@
|
||||
/**
|
||||
* Аудит CSS стилей на странице чата
|
||||
* Проверяет какие стили используются, а какие нет
|
||||
*
|
||||
* Запуск из консоли браузера:
|
||||
*
|
||||
* 1. Загрузить скрипт:
|
||||
* const script = document.createElement('script');
|
||||
* script.src = '/static/js/tests/chatStylesAudit.js';
|
||||
* script.type = 'module';
|
||||
* document.head.appendChild(script);
|
||||
*
|
||||
* 2. Запустить аудит:
|
||||
* window.ChatStylesAudit.runFullAudit()
|
||||
* window.ChatStylesAudit.checkChatDetailStyles()
|
||||
* window.ChatStylesAudit.checkMessageStyles()
|
||||
* window.ChatStylesAudit.findUnusedStyles()
|
||||
*/
|
||||
|
||||
class ChatStylesAudit {
|
||||
constructor() {
|
||||
this.results = {
|
||||
total: 0,
|
||||
used: 0,
|
||||
unused: 0,
|
||||
details: []
|
||||
};
|
||||
|
||||
// CSS файлы для проверки
|
||||
this.cssFiles = [
|
||||
'/static/css/components/chat-detail.css',
|
||||
'/static/css/message-reactions.css',
|
||||
'/static/css/message-context-menu.css',
|
||||
'/static/css/message-selection.css',
|
||||
'/static/css/components/chat-polls.css'
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Получает все CSS правила из stylesheet
|
||||
*/
|
||||
getCSSRules(stylesheet) {
|
||||
try {
|
||||
return Array.from(stylesheet.cssRules || stylesheet.rules || []);
|
||||
} catch (e) {
|
||||
console.warn('Не удалось получить правила из stylesheet:', e);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Извлекает селекторы из CSS правила
|
||||
*/
|
||||
extractSelectors(rule) {
|
||||
const selectors = [];
|
||||
|
||||
if (rule.selectorText) {
|
||||
// Разбиваем составные селекторы
|
||||
const parts = rule.selectorText.split(',').map(s => s.trim());
|
||||
selectors.push(...parts);
|
||||
}
|
||||
|
||||
// Рекурсивно обрабатываем медиа-запросы
|
||||
if (rule.cssRules) {
|
||||
for (const subRule of this.getCSSRules(rule)) {
|
||||
selectors.push(...this.extractSelectors(subRule));
|
||||
}
|
||||
}
|
||||
|
||||
return selectors;
|
||||
}
|
||||
|
||||
/**
|
||||
* Проверяет используется ли селектор в DOM
|
||||
*/
|
||||
isSelectorUsed(selector) {
|
||||
try {
|
||||
// Пропускаем псевдо-элементы и псевдо-классы для точной проверки
|
||||
const cleanSelector = selector
|
||||
.replace(/::(before|after|placeholder|selection)/g, '')
|
||||
.replace(/:(hover|focus|active|visited|link|first-child|last-child|nth-child\([^)]+\)|not\([^)]+\))/g, '')
|
||||
.trim();
|
||||
|
||||
if (!cleanSelector || cleanSelector === '') {
|
||||
return { used: false, reason: 'empty_after_cleanup' };
|
||||
}
|
||||
|
||||
const elements = document.querySelectorAll(cleanSelector);
|
||||
return {
|
||||
used: elements.length > 0,
|
||||
count: elements.length,
|
||||
selector: cleanSelector
|
||||
};
|
||||
} catch (e) {
|
||||
return { used: false, reason: 'invalid_selector', error: e.message };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Сканирует все стили связанные с чатом
|
||||
*/
|
||||
async scanChatStyles() {
|
||||
console.log('🔍 Сканирование CSS стилей чата...\n');
|
||||
|
||||
const allSelectors = new Map(); // selector -> { used, files, count }
|
||||
|
||||
// Проходим по всем загруженным stylesheets
|
||||
for (const stylesheet of document.styleSheets) {
|
||||
try {
|
||||
const href = stylesheet.href || 'inline';
|
||||
|
||||
// Проверяем только файлы связанные с чатом
|
||||
const isChatRelated = this.cssFiles.some(file => href.includes(file)) ||
|
||||
href.includes('chat') ||
|
||||
href.includes('message');
|
||||
|
||||
if (!isChatRelated && stylesheet.href) continue;
|
||||
|
||||
const rules = this.getCSSRules(stylesheet);
|
||||
|
||||
for (const rule of rules) {
|
||||
const selectors = this.extractSelectors(rule);
|
||||
|
||||
for (const selector of selectors) {
|
||||
if (!allSelectors.has(selector)) {
|
||||
const usage = this.isSelectorUsed(selector);
|
||||
allSelectors.set(selector, {
|
||||
original: selector,
|
||||
used: usage.used,
|
||||
count: usage.count || 0,
|
||||
files: [href],
|
||||
reason: usage.reason
|
||||
});
|
||||
} else {
|
||||
const existing = allSelectors.get(selector);
|
||||
if (!existing.files.includes(href)) {
|
||||
existing.files.push(href);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Ошибка при обработке stylesheet:', e);
|
||||
}
|
||||
}
|
||||
|
||||
return allSelectors;
|
||||
}
|
||||
|
||||
/**
|
||||
* Проверяет конкретный CSS файл
|
||||
*/
|
||||
async checkSpecificFile(filePath) {
|
||||
console.log(`📄 Проверка файла: ${filePath}\n`);
|
||||
|
||||
const results = {
|
||||
file: filePath,
|
||||
total: 0,
|
||||
used: 0,
|
||||
unused: 0,
|
||||
selectors: []
|
||||
};
|
||||
|
||||
for (const stylesheet of document.styleSheets) {
|
||||
if (stylesheet.href && stylesheet.href.includes(filePath)) {
|
||||
const rules = this.getCSSRules(stylesheet);
|
||||
|
||||
for (const rule of rules) {
|
||||
const selectors = this.extractSelectors(rule);
|
||||
|
||||
for (const selector of selectors) {
|
||||
const usage = this.isSelectorUsed(selector);
|
||||
results.total++;
|
||||
|
||||
if (usage.used) {
|
||||
results.used++;
|
||||
} else {
|
||||
results.unused++;
|
||||
}
|
||||
|
||||
results.selectors.push({
|
||||
selector,
|
||||
used: usage.used,
|
||||
count: usage.count || 0,
|
||||
reason: usage.reason
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Полный аудит всех стилей
|
||||
*/
|
||||
async runFullAudit() {
|
||||
console.log('\n' + '='.repeat(80));
|
||||
console.log('🎨 ПОЛНЫЙ АУДИТ CSS СТИЛЕЙ ЧАТА');
|
||||
console.log('='.repeat(80) + '\n');
|
||||
|
||||
const allSelectors = await this.scanChatStyles();
|
||||
|
||||
const used = [];
|
||||
const unused = [];
|
||||
|
||||
for (const [selector, data] of allSelectors) {
|
||||
if (data.used) {
|
||||
used.push({ selector, ...data });
|
||||
} else {
|
||||
unused.push({ selector, ...data });
|
||||
}
|
||||
}
|
||||
|
||||
// Сортируем по количеству использований
|
||||
used.sort((a, b) => b.count - a.count);
|
||||
unused.sort((a, b) => a.selector.localeCompare(b.selector));
|
||||
|
||||
console.log('📊 СТАТИСТИКА:');
|
||||
console.log('─'.repeat(80));
|
||||
console.log(`Всего селекторов: ${allSelectors.size}`);
|
||||
console.log(`✅ Используется: ${used.length} (${(used.length / allSelectors.size * 100).toFixed(1)}%)`);
|
||||
console.log(`❌ Не используется: ${unused.length} (${(unused.length / allSelectors.size * 100).toFixed(1)}%)`);
|
||||
console.log('─'.repeat(80) + '\n');
|
||||
|
||||
// ТОП-10 самых используемых стилей
|
||||
console.log('🏆 ТОП-10 САМЫХ ИСПОЛЬЗУЕМЫХ СТИЛЕЙ:');
|
||||
console.log('─'.repeat(80));
|
||||
used.slice(0, 10).forEach((item, i) => {
|
||||
console.log(`${i + 1}. ${item.selector}`);
|
||||
console.log(` 📍 Использований: ${item.count}`);
|
||||
console.log(` 📁 Файлы: ${item.files.join(', ')}\n`);
|
||||
});
|
||||
|
||||
// Неиспользуемые стили
|
||||
if (unused.length > 0) {
|
||||
console.log('\n❌ НЕИСПОЛЬЗУЕМЫЕ СТИЛИ:');
|
||||
console.log('─'.repeat(80));
|
||||
|
||||
// Группируем по файлам
|
||||
const byFile = {};
|
||||
unused.forEach(item => {
|
||||
item.files.forEach(file => {
|
||||
if (!byFile[file]) byFile[file] = [];
|
||||
byFile[file].push(item.selector);
|
||||
});
|
||||
});
|
||||
|
||||
for (const [file, selectors] of Object.entries(byFile)) {
|
||||
const fileName = file.split('/').pop();
|
||||
console.log(`\n📄 ${fileName} (${selectors.length} неиспользуемых):`);
|
||||
selectors.slice(0, 20).forEach(sel => {
|
||||
console.log(` • ${sel}`);
|
||||
});
|
||||
if (selectors.length > 20) {
|
||||
console.log(` ... и ещё ${selectors.length - 20}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log('\n' + '='.repeat(80));
|
||||
console.log('✅ АУДИТ ЗАВЕРШЁН');
|
||||
console.log('='.repeat(80) + '\n');
|
||||
|
||||
// Сохраняем результаты
|
||||
this.lastAudit = {
|
||||
timestamp: new Date().toISOString(),
|
||||
total: allSelectors.size,
|
||||
used: used.length,
|
||||
unused: unused.length,
|
||||
usedSelectors: used,
|
||||
unusedSelectors: unused
|
||||
};
|
||||
|
||||
return this.lastAudit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Проверяет стили chat-detail.css
|
||||
*/
|
||||
async checkChatDetailStyles() {
|
||||
console.log('\n📋 АУДИТ: chat-detail.css\n');
|
||||
const result = await this.checkSpecificFile('chat-detail.css');
|
||||
this.printFileResults(result);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Проверяет стили сообщений
|
||||
*/
|
||||
async checkMessageStyles() {
|
||||
console.log('\n📋 АУДИТ: Стили сообщений\n');
|
||||
|
||||
const files = [
|
||||
'message-reactions.css',
|
||||
'message-context-menu.css',
|
||||
'message-selection.css'
|
||||
];
|
||||
|
||||
const results = [];
|
||||
for (const file of files) {
|
||||
const result = await this.checkSpecificFile(file);
|
||||
results.push(result);
|
||||
}
|
||||
|
||||
// Общая статистика
|
||||
const total = results.reduce((sum, r) => sum + r.total, 0);
|
||||
const used = results.reduce((sum, r) => sum + r.used, 0);
|
||||
const unused = results.reduce((sum, r) => sum + r.unused, 0);
|
||||
|
||||
console.log('\n' + '='.repeat(60));
|
||||
console.log('📊 ОБЩАЯ СТАТИСТИКА ПО СТИЛЯМ СООБЩЕНИЙ:');
|
||||
console.log('='.repeat(60));
|
||||
console.log(`Всего: ${total}`);
|
||||
console.log(`✅ Используется: ${used} (${(used / total * 100).toFixed(1)}%)`);
|
||||
console.log(`❌ Не используется: ${unused} (${(unused / total * 100).toFixed(1)}%)`);
|
||||
console.log('='.repeat(60) + '\n');
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Находит дубликаты стилей
|
||||
*/
|
||||
findDuplicateStyles() {
|
||||
console.log('\n🔎 ПОИСК ДУБЛИРУЮЩИХСЯ СТИЛЕЙ...\n');
|
||||
|
||||
const styleMap = new Map(); // CSS text -> selectors[]
|
||||
|
||||
for (const stylesheet of document.styleSheets) {
|
||||
try {
|
||||
const rules = this.getCSSRules(stylesheet);
|
||||
|
||||
for (const rule of rules) {
|
||||
if (rule.style && rule.selectorText) {
|
||||
const cssText = rule.style.cssText;
|
||||
if (!styleMap.has(cssText)) {
|
||||
styleMap.set(cssText, []);
|
||||
}
|
||||
styleMap.get(cssText).push(rule.selectorText);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// Skip
|
||||
}
|
||||
}
|
||||
|
||||
const duplicates = [];
|
||||
for (const [cssText, selectors] of styleMap) {
|
||||
if (selectors.length > 1) {
|
||||
duplicates.push({ cssText, selectors, count: selectors.length });
|
||||
}
|
||||
}
|
||||
|
||||
duplicates.sort((a, b) => b.count - a.count);
|
||||
|
||||
console.log(`Найдено ${duplicates.length} групп дублирующихся стилей:\n`);
|
||||
|
||||
duplicates.slice(0, 10).forEach((dup, i) => {
|
||||
console.log(`${i + 1}. ${dup.count} дубликатов:`);
|
||||
console.log(` Стили: ${dup.cssText.substring(0, 100)}${dup.cssText.length > 100 ? '...' : ''}`);
|
||||
console.log(` Селекторы:`);
|
||||
dup.selectors.forEach(sel => console.log(` • ${sel}`));
|
||||
console.log('');
|
||||
});
|
||||
|
||||
return duplicates;
|
||||
}
|
||||
|
||||
/**
|
||||
* Выводит результаты для файла
|
||||
*/
|
||||
printFileResults(result) {
|
||||
console.log('─'.repeat(60));
|
||||
console.log(`📄 Файл: ${result.file}`);
|
||||
console.log('─'.repeat(60));
|
||||
console.log(`Всего селекторов: ${result.total}`);
|
||||
console.log(`✅ Используется: ${result.used} (${(result.used / result.total * 100).toFixed(1)}%)`);
|
||||
console.log(`❌ Не используется: ${result.unused} (${(result.unused / result.total * 100).toFixed(1)}%)`);
|
||||
console.log('─'.repeat(60));
|
||||
|
||||
if (result.unused > 0) {
|
||||
const unusedList = result.selectors.filter(s => !s.used);
|
||||
console.log(`\n❌ Неиспользуемые селекторы (${unusedList.length}):`);
|
||||
unusedList.slice(0, 20).forEach(s => {
|
||||
console.log(` • ${s.selector}`);
|
||||
});
|
||||
if (unusedList.length > 20) {
|
||||
console.log(` ... и ещё ${unusedList.length - 20}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Анализирует специфичность селекторов
|
||||
*/
|
||||
analyzeSpecificity() {
|
||||
console.log('\n⚖️ АНАЛИЗ СПЕЦИФИЧНОСТИ СЕЛЕКТОРОВ...\n');
|
||||
|
||||
const specificityMap = new Map();
|
||||
|
||||
for (const stylesheet of document.styleSheets) {
|
||||
try {
|
||||
const rules = this.getCSSRules(stylesheet);
|
||||
|
||||
for (const rule of rules) {
|
||||
if (rule.selectorText) {
|
||||
const selectors = rule.selectorText.split(',').map(s => s.trim());
|
||||
|
||||
for (const selector of selectors) {
|
||||
const spec = this.calculateSpecificity(selector);
|
||||
const key = spec.join('-');
|
||||
|
||||
if (!specificityMap.has(key)) {
|
||||
specificityMap.set(key, []);
|
||||
}
|
||||
specificityMap.get(key).push(selector);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// Skip
|
||||
}
|
||||
}
|
||||
|
||||
const entries = Array.from(specificityMap.entries())
|
||||
.map(([key, selectors]) => ({
|
||||
specificity: key,
|
||||
count: selectors.length,
|
||||
examples: selectors.slice(0, 3)
|
||||
}))
|
||||
.sort((a, b) => b.count - a.count);
|
||||
|
||||
console.log('📊 Распределение по специфичности:\n');
|
||||
entries.slice(0, 15).forEach((entry, i) => {
|
||||
console.log(`${i + 1}. [${entry.specificity}] - ${entry.count} селекторов`);
|
||||
entry.examples.forEach(ex => console.log(` ${ex}`));
|
||||
console.log('');
|
||||
});
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
/**
|
||||
* Вычисляет специфичность селектора
|
||||
*/
|
||||
calculateSpecificity(selector) {
|
||||
let ids = 0;
|
||||
let classes = 0;
|
||||
let elements = 0;
|
||||
|
||||
// Подсчет ID
|
||||
ids = (selector.match(/#/g) || []).length;
|
||||
|
||||
// Подсчет классов, атрибутов, псевдоклассов
|
||||
classes = (selector.match(/\./g) || []).length;
|
||||
classes += (selector.match(/\[/g) || []).length;
|
||||
classes += (selector.match(/:/g) || []).length;
|
||||
|
||||
// Подсчет элементов и псевдоэлементов
|
||||
elements = (selector.match(/\b[a-z]+\b/g) || []).length;
|
||||
elements += (selector.match(/::/g) || []).length;
|
||||
|
||||
return [ids, classes, elements];
|
||||
}
|
||||
|
||||
/**
|
||||
* Экспортирует результаты в JSON
|
||||
*/
|
||||
exportResults() {
|
||||
if (!this.lastAudit) {
|
||||
console.error('❌ Сначала запустите runFullAudit()');
|
||||
return;
|
||||
}
|
||||
|
||||
const json = JSON.stringify(this.lastAudit, null, 2);
|
||||
const blob = new Blob([json], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `chat-styles-audit-${Date.now()}.json`;
|
||||
a.click();
|
||||
|
||||
console.log('✅ Результаты экспортированы в JSON');
|
||||
}
|
||||
}
|
||||
|
||||
// Экспортируем в глобальную область
|
||||
window.ChatStylesAudit = new ChatStylesAudit();
|
||||
|
||||
export default ChatStylesAudit;
|
||||
1476
backend/static/js/tests/chatTests.js
Normal file
1476
backend/static/js/tests/chatTests.js
Normal file
File diff suppressed because it is too large
Load Diff
278
backend/static/js/tests/runTests.html
Normal file
278
backend/static/js/tests/runTests.html
Normal file
@ -0,0 +1,278 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Chat Tests Runner</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
background: #f5f5f5;
|
||||
}
|
||||
.header {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
padding: 30px;
|
||||
border-radius: 10px;
|
||||
margin-bottom: 30px;
|
||||
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
|
||||
}
|
||||
.header h1 {
|
||||
margin: 0 0 10px 0;
|
||||
}
|
||||
.header p {
|
||||
margin: 0;
|
||||
opacity: 0.9;
|
||||
}
|
||||
.test-buttons {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||
gap: 15px;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
.test-button {
|
||||
background: white;
|
||||
border: 2px solid #667eea;
|
||||
color: #667eea;
|
||||
padding: 15px 20px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
transition: all 0.3s ease;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
}
|
||||
.test-button:hover {
|
||||
background: #667eea;
|
||||
color: white;
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 8px rgba(0,0,0,0.2);
|
||||
}
|
||||
.test-button.primary {
|
||||
background: #667eea;
|
||||
color: white;
|
||||
font-size: 18px;
|
||||
}
|
||||
.test-button.primary:hover {
|
||||
background: #5568d3;
|
||||
}
|
||||
.console-output {
|
||||
background: #1e1e1e;
|
||||
color: #d4d4d4;
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
font-family: 'Consolas', 'Monaco', monospace;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
min-height: 400px;
|
||||
max-height: 600px;
|
||||
overflow-y: auto;
|
||||
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.console-output pre {
|
||||
margin: 0;
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
.info-box {
|
||||
background: white;
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 20px;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
}
|
||||
.info-box h3 {
|
||||
margin-top: 0;
|
||||
color: #667eea;
|
||||
}
|
||||
.info-box code {
|
||||
background: #f0f0f0;
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
font-family: 'Consolas', monospace;
|
||||
}
|
||||
.info-box ul {
|
||||
margin: 10px 0;
|
||||
padding-left: 20px;
|
||||
}
|
||||
.status {
|
||||
padding: 10px 20px;
|
||||
border-radius: 5px;
|
||||
margin-bottom: 20px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.status.running {
|
||||
background: #fff3cd;
|
||||
color: #856404;
|
||||
border: 1px solid #ffeaa7;
|
||||
}
|
||||
.status.success {
|
||||
background: #d4edda;
|
||||
color: #155724;
|
||||
border: 1px solid #c3e6cb;
|
||||
}
|
||||
.status.error {
|
||||
background: #f8d7da;
|
||||
color: #721c24;
|
||||
border: 1px solid #f5c6cb;
|
||||
}
|
||||
.hidden {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<h1>🧪 Chat System Test Runner</h1>
|
||||
<p>Комплексное тестирование системы чата EUSRR</p>
|
||||
</div>
|
||||
|
||||
<div class="info-box">
|
||||
<h3>📋 Инструкции</h3>
|
||||
<ul>
|
||||
<li>Нажмите на любую кнопку ниже для запуска соответствующего набора тестов</li>
|
||||
<li>Результаты будут отображены в консоли браузера (F12) и в окне ниже</li>
|
||||
<li>Для запуска всех тестов используйте кнопку "Запустить все тесты"</li>
|
||||
<li>Тесты можно также запускать из консоли браузера: <code>window.ChatTests.runAll()</code></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div id="status" class="status hidden"></div>
|
||||
|
||||
<div class="test-buttons">
|
||||
<button class="test-button primary" onclick="runAllTests()">
|
||||
🚀 Запустить все тесты
|
||||
</button>
|
||||
<button class="test-button" onclick="runTest('testMessageStore')">
|
||||
📦 MessageStore
|
||||
</button>
|
||||
<button class="test-button" onclick="runTest('testMessageLoader')">
|
||||
📥 MessageLoader
|
||||
</button>
|
||||
<button class="test-button" onclick="runTest('testScrollManager')">
|
||||
📜 ScrollManager
|
||||
</button>
|
||||
<button class="test-button" onclick="runTest('testMessageRenderer')">
|
||||
🎨 MessageRenderer
|
||||
</button>
|
||||
<button class="test-button" onclick="runTest('testChatController')">
|
||||
🎮 ChatController
|
||||
</button>
|
||||
<button class="test-button" onclick="runTest('testIntegration')">
|
||||
🔗 Интеграционные
|
||||
</button>
|
||||
<button class="test-button" onclick="runTest('testPerformance')">
|
||||
⚡ Производительность
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="console-output" id="console">
|
||||
<pre>Консоль готова к выполнению тестов...</pre>
|
||||
</div>
|
||||
|
||||
<div class="info-box">
|
||||
<h3>📊 Что тестируется?</h3>
|
||||
<ul>
|
||||
<li><strong>MessageStore:</strong> Хранение, добавление, обновление, удаление сообщений</li>
|
||||
<li><strong>MessageLoader:</strong> Загрузка начальных сообщений и истории</li>
|
||||
<li><strong>ScrollManager:</strong> Управление скроллом и lazy loading</li>
|
||||
<li><strong>MessageRenderer:</strong> Рендеринг сообщений в DOM</li>
|
||||
<li><strong>ChatController:</strong> Общая координация работы системы</li>
|
||||
<li><strong>Интеграционные:</strong> Взаимодействие всех компонентов</li>
|
||||
<li><strong>Производительность:</strong> Тесты скорости работы с большими объемами данных</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Объявляем функции глобально ДО загрузки модуля
|
||||
window.runAllTests = null;
|
||||
window.runTest = null;
|
||||
</script>
|
||||
|
||||
<script type="module">
|
||||
import './chatTests.js';
|
||||
|
||||
// Перехватываем console.log для отображения в UI
|
||||
const consoleOutput = document.getElementById('console');
|
||||
const originalLog = console.log;
|
||||
const originalError = console.error;
|
||||
const originalWarn = console.warn;
|
||||
|
||||
function addToConsole(message, type = 'log') {
|
||||
const pre = consoleOutput.querySelector('pre');
|
||||
const color = type === 'error' ? '#f48771' : type === 'warn' ? '#dcdcaa' : '#d4d4d4';
|
||||
const line = document.createElement('div');
|
||||
line.style.color = color;
|
||||
line.textContent = message;
|
||||
pre.appendChild(line);
|
||||
consoleOutput.scrollTop = consoleOutput.scrollHeight;
|
||||
}
|
||||
|
||||
console.log = function(...args) {
|
||||
originalLog.apply(console, args);
|
||||
addToConsole(args.join(' '), 'log');
|
||||
};
|
||||
|
||||
console.error = function(...args) {
|
||||
originalError.apply(console, args);
|
||||
addToConsole('ERROR: ' + args.join(' '), 'error');
|
||||
};
|
||||
|
||||
console.warn = function(...args) {
|
||||
originalWarn.apply(console, args);
|
||||
addToConsole('WARN: ' + args.join(' '), 'warn');
|
||||
};
|
||||
|
||||
// Присваиваем функции глобальным переменным
|
||||
window.runAllTests = async function() {
|
||||
const status = document.getElementById('status');
|
||||
status.className = 'status running';
|
||||
status.textContent = '⏳ Выполнение всех тестов...';
|
||||
status.classList.remove('hidden');
|
||||
|
||||
const pre = consoleOutput.querySelector('pre');
|
||||
pre.innerHTML = '';
|
||||
|
||||
try {
|
||||
const results = await window.ChatTests.runAll();
|
||||
|
||||
status.className = 'status ' + (results.failed === 0 ? 'success' : 'error');
|
||||
status.textContent = results.failed === 0
|
||||
? `✅ Все тесты пройдены! (${results.passed}/${results.total})`
|
||||
: `⚠️ Некоторые тесты провалены: ${results.passed}/${results.total} успешно, ${results.failed} провалено`;
|
||||
} catch (error) {
|
||||
status.className = 'status error';
|
||||
status.textContent = '❌ Ошибка при выполнении тестов: ' + error.message;
|
||||
}
|
||||
};
|
||||
|
||||
window.runTest = async function(testName) {
|
||||
const status = document.getElementById('status');
|
||||
status.className = 'status running';
|
||||
status.textContent = `⏳ Выполнение ${testName}...`;
|
||||
status.classList.remove('hidden');
|
||||
|
||||
const pre = consoleOutput.querySelector('pre');
|
||||
pre.innerHTML = '';
|
||||
|
||||
try {
|
||||
const results = await window.ChatTests[testName]();
|
||||
|
||||
status.className = 'status ' + (results.failed === 0 ? 'success' : 'error');
|
||||
status.textContent = results.failed === 0
|
||||
? `✅ Тест ${testName} пройден! (${results.passed}/${results.total})`
|
||||
: `⚠️ Тест ${testName}: ${results.passed}/${results.total} успешно, ${results.failed} провалено`;
|
||||
} catch (error) {
|
||||
status.className = 'status error';
|
||||
status.textContent = '❌ Ошибка: ' + error.message;
|
||||
}
|
||||
};
|
||||
|
||||
console.log('✅ Test Runner готов к работе!');
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
307
backend/static/js/utils/chatUtils.js
Normal file
307
backend/static/js/utils/chatUtils.js
Normal file
@ -0,0 +1,307 @@
|
||||
/**
|
||||
* @fileoverview Chat Utilities - вспомогательные функции для чата
|
||||
* @module utils/chatUtils
|
||||
*/
|
||||
|
||||
/**
|
||||
* Создает debounced функцию
|
||||
* @param {Function} fn - Функция для debounce
|
||||
* @param {number} delay - Задержка в мс
|
||||
* @returns {Function}
|
||||
*/
|
||||
export function debounce(fn, delay) {
|
||||
let timeoutId = null;
|
||||
|
||||
function debounced(...args) {
|
||||
if (timeoutId) {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
timeoutId = setTimeout(() => {
|
||||
fn.apply(this, args);
|
||||
timeoutId = null;
|
||||
}, delay);
|
||||
}
|
||||
|
||||
debounced.cancel = () => {
|
||||
if (timeoutId) {
|
||||
clearTimeout(timeoutId);
|
||||
timeoutId = null;
|
||||
}
|
||||
};
|
||||
|
||||
return debounced;
|
||||
}
|
||||
|
||||
/**
|
||||
* Создает throttled функцию
|
||||
* @param {Function} fn - Функция для throttle
|
||||
* @param {number} limit - Минимальный интервал в мс
|
||||
* @returns {Function}
|
||||
*/
|
||||
export function throttle(fn, limit) {
|
||||
let inThrottle = false;
|
||||
let lastArgs = null;
|
||||
|
||||
return function(...args) {
|
||||
if (!inThrottle) {
|
||||
fn.apply(this, args);
|
||||
inThrottle = true;
|
||||
setTimeout(() => {
|
||||
inThrottle = false;
|
||||
if (lastArgs) {
|
||||
fn.apply(this, lastArgs);
|
||||
lastArgs = null;
|
||||
}
|
||||
}, limit);
|
||||
} else {
|
||||
lastArgs = args;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Экранирует HTML для безопасного вывода
|
||||
* @param {string} text - Текст для экранирования
|
||||
* @returns {string}
|
||||
*/
|
||||
export function escapeHtml(text) {
|
||||
if (!text) return '';
|
||||
const map = {
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": '''
|
||||
};
|
||||
return String(text).replace(/[&<>"']/g, m => map[m]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Форматирует дату в текст дня
|
||||
* @param {Date|number} date - Дата или timestamp
|
||||
* @returns {string}
|
||||
*/
|
||||
export function formatDay(date) {
|
||||
const d = date instanceof Date ? date : new Date(date);
|
||||
const today = new Date();
|
||||
const yesterday = new Date(today);
|
||||
yesterday.setDate(yesterday.getDate() - 1);
|
||||
|
||||
if (d.toDateString() === today.toDateString()) return 'Сегодня';
|
||||
if (d.toDateString() === yesterday.toDateString()) return 'Вчера';
|
||||
|
||||
return d.toLocaleDateString('ru-RU', {
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
year: 'numeric'
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Форматирует время
|
||||
* @param {Date|number} date - Дата или timestamp
|
||||
* @returns {string}
|
||||
*/
|
||||
export function formatTime(date) {
|
||||
const d = date instanceof Date ? date : new Date(date);
|
||||
return d.toLocaleTimeString('ru-RU', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Форматирует дату и время
|
||||
* @param {Date|number} date - Дата или timestamp
|
||||
* @returns {string}
|
||||
*/
|
||||
export function formatDateTime(date) {
|
||||
return `${formatDay(date)}, ${formatTime(date)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Форматирует размер файла
|
||||
* @param {number} bytes - Размер в байтах
|
||||
* @returns {string}
|
||||
*/
|
||||
export function formatFileSize(bytes) {
|
||||
if (bytes === 0) return '0 B';
|
||||
|
||||
const units = ['B', 'KB', 'MB', 'GB'];
|
||||
const k = 1024;
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
|
||||
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${units[i]}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Генерирует уникальный ID
|
||||
* @param {string} [prefix=''] - Префикс
|
||||
* @returns {string}
|
||||
*/
|
||||
export function generateId(prefix = '') {
|
||||
const random = Math.random().toString(36).substr(2, 9);
|
||||
return prefix ? `${prefix}_${Date.now()}_${random}` : `${Date.now()}_${random}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ждет указанное время
|
||||
* @param {number} ms - Миллисекунды
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export function delay(ms) {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
/**
|
||||
* Ждет следующего кадра анимации
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export function nextFrame() {
|
||||
return new Promise(resolve => requestAnimationFrame(resolve));
|
||||
}
|
||||
|
||||
/**
|
||||
* Ждет двойного кадра (гарантия после layout)
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export function afterLayout() {
|
||||
return new Promise(resolve => {
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(resolve);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Проверяет, виден ли элемент в контейнере
|
||||
* @param {HTMLElement} element - Элемент
|
||||
* @param {HTMLElement} container - Контейнер
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isElementVisible(element, container) {
|
||||
const elRect = element.getBoundingClientRect();
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
|
||||
return (
|
||||
elRect.top >= containerRect.top &&
|
||||
elRect.bottom <= containerRect.bottom
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Проверяет, частично виден ли элемент в контейнере
|
||||
* @param {HTMLElement} element - Элемент
|
||||
* @param {HTMLElement} container - Контейнер
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isElementPartiallyVisible(element, container) {
|
||||
const elRect = element.getBoundingClientRect();
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
|
||||
return (
|
||||
elRect.bottom >= containerRect.top &&
|
||||
elRect.top <= containerRect.bottom
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Копирует текст в буфер обмена
|
||||
* @param {string} text - Текст для копирования
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
export async function copyToClipboard(text) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
return true;
|
||||
} catch {
|
||||
// Fallback для старых браузеров
|
||||
const textarea = document.createElement('textarea');
|
||||
textarea.value = text;
|
||||
textarea.style.position = 'fixed';
|
||||
textarea.style.opacity = '0';
|
||||
document.body.appendChild(textarea);
|
||||
textarea.select();
|
||||
|
||||
try {
|
||||
document.execCommand('copy');
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
} finally {
|
||||
document.body.removeChild(textarea);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Парсит булево значение из строки
|
||||
* @param {any} value - Значение
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function parseBool(value) {
|
||||
if (typeof value === 'boolean') return value;
|
||||
if (value === '1' || value === 'true') return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Глубокое клонирование объекта
|
||||
* @param {Object} obj - Объект для клонирования
|
||||
* @returns {Object}
|
||||
*/
|
||||
export function deepClone(obj) {
|
||||
if (obj === null || typeof obj !== 'object') return obj;
|
||||
if (Array.isArray(obj)) return obj.map(deepClone);
|
||||
|
||||
const clone = {};
|
||||
for (const key in obj) {
|
||||
if (Object.prototype.hasOwnProperty.call(obj, key)) {
|
||||
clone[key] = deepClone(obj[key]);
|
||||
}
|
||||
}
|
||||
return clone;
|
||||
}
|
||||
|
||||
/**
|
||||
* Сравнивает два объекта на равенство
|
||||
* @param {any} a - Первый объект
|
||||
* @param {any} b - Второй объект
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isEqual(a, b) {
|
||||
if (a === b) return true;
|
||||
if (a == null || b == null) return false;
|
||||
if (typeof a !== typeof b) return false;
|
||||
|
||||
if (typeof a === 'object') {
|
||||
const keysA = Object.keys(a);
|
||||
const keysB = Object.keys(b);
|
||||
|
||||
if (keysA.length !== keysB.length) return false;
|
||||
|
||||
return keysA.every(key => isEqual(a[key], b[key]));
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export default {
|
||||
debounce,
|
||||
throttle,
|
||||
escapeHtml,
|
||||
formatDay,
|
||||
formatTime,
|
||||
formatDateTime,
|
||||
formatFileSize,
|
||||
generateId,
|
||||
delay,
|
||||
nextFrame,
|
||||
afterLayout,
|
||||
isElementVisible,
|
||||
isElementPartiallyVisible,
|
||||
copyToClipboard,
|
||||
parseBool,
|
||||
deepClone,
|
||||
isEqual
|
||||
};
|
||||
@ -98,7 +98,8 @@
|
||||
data-messages-url="{% url 'api:v1:chat_messages' chat.pk %}"
|
||||
data-mark-read-url="{% url 'communications:chat_mark_read' chat.pk %}"
|
||||
data-edit-url-template="/api/v1/communications/messages/{id}/edit/"
|
||||
data-last-read-timestamp="{{ chat.last_read_timestamp|default:'0' }}">
|
||||
data-last-read-timestamp="{{ chat.last_read_timestamp|default:'0' }}"
|
||||
data-last-read-message-id="{% if chat.last_read_message_id %}{{ chat.last_read_message_id }}{% else %}0{% endif %}">
|
||||
|
||||
<!-- Предупреждение о блокировке -->
|
||||
{% if chat.is_blocked %}
|
||||
|
||||
369
docs/in-progress/REFACTORING_PHASE1_COMPLETE.md
Normal file
369
docs/in-progress/REFACTORING_PHASE1_COMPLETE.md
Normal file
@ -0,0 +1,369 @@
|
||||
# Прогресс рефакторинга - Фаза 2 в процессе
|
||||
|
||||
**Дата:** 12.01.2026
|
||||
**Статус:** ✅ Фаза 1 DONE | 🔄 Фаза 2 в процессе (7/10 задач)
|
||||
|
||||
---
|
||||
|
||||
## ✅ Что сделано
|
||||
|
||||
### 1. MessageStore - Централизованное хранилище ✅
|
||||
**Файл:** `backend/static/js/stores/messageStore.js` (458 строк)
|
||||
|
||||
**Функционал:**
|
||||
- ✅ Map структура для сообщений (id → message)
|
||||
- ✅ Сортированные списки по чатам (chatId → [messageIds])
|
||||
- ✅ Observer pattern (подписки на изменения)
|
||||
- ✅ Оптимистичные обновления (temp_id → real_id)
|
||||
- ✅ Автоматическое вычисление day-dividers
|
||||
- ✅ Методы: add, update, remove, get, subscribe
|
||||
|
||||
**Преимущества:**
|
||||
- Единственный источник правды
|
||||
- Нет дубликатов
|
||||
- Легко откатывать изменения
|
||||
- Централизованное кэширование
|
||||
|
||||
---
|
||||
|
||||
### 2. MessageStore.test.js - Тесты ✅
|
||||
**Файл:** `backend/static/js/stores/messageStore.test.js` (222 строки)
|
||||
|
||||
**Покрытие:**
|
||||
- ✅ 12 тестовых кейсов
|
||||
- ✅ Создание Store
|
||||
- ✅ Добавление/обновление/удаление
|
||||
- ✅ Batch операции
|
||||
- ✅ Предотвращение дубликатов
|
||||
- ✅ Сортировка по timestamp
|
||||
- ✅ Day-dividers вычисление
|
||||
- ✅ Подписки (subscribe/unsubscribe)
|
||||
- ✅ Оптимистичные сообщения
|
||||
- ✅ Oldest/Newest message
|
||||
|
||||
**Запуск:**
|
||||
```javascript
|
||||
// В консоли браузера
|
||||
runMessageStoreTests()
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3. MessageLoader - Единый загрузчик ✅
|
||||
**Файл:** `backend/static/js/loaders/messageLoader.js` (292 строки)
|
||||
|
||||
**Функционал:**
|
||||
- ✅ loadInitialMessages() - начальная загрузка
|
||||
- ✅ loadHistoryBefore() - история при scroll up
|
||||
- ✅ handleNewMessage() - новые сообщения из WS
|
||||
- ✅ handleMessageEdited/Removed() - обновления
|
||||
- ✅ handleReaction*() - реакции
|
||||
- ✅ sendMessageOptimistically() - оптимистичная отправка
|
||||
- ✅ Tracking состояния (loading, hasMore)
|
||||
|
||||
**API:**
|
||||
```javascript
|
||||
const loader = new MessageLoader({
|
||||
store: messageStore,
|
||||
wsConnection: userWebSocket,
|
||||
currentUserId: userId
|
||||
});
|
||||
|
||||
await loader.loadInitialMessages(chatId);
|
||||
await loader.loadHistoryBefore(chatId, { limit: 20 });
|
||||
loader.handleNewMessage(message);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 4. MessageRendererV2 - Рендеринг из Store ✅
|
||||
**Файл:** `backend/static/js/renderers/messageRendererV2.js` (397 строк)
|
||||
|
||||
**Функционал:**
|
||||
- ✅ render(chatId) - полный рендеринг из Store
|
||||
- ✅ appendMessage() - incremental добавление
|
||||
- ✅ updateMessage() - патчинг DOM (content, reactions, status)
|
||||
- ✅ removeMessage() - удаление
|
||||
- ✅ НЕТ дублирования логики day-dividers
|
||||
- ✅ Использует Store.getMessagesWithDividers()
|
||||
- ✅ Tracking отрендеренных сообщений (Set)
|
||||
|
||||
**API:**
|
||||
```javascript
|
||||
const renderer = new MessageRendererV2({
|
||||
store: messageStore,
|
||||
containerId: 'chatScroll',
|
||||
currentUserId: userId
|
||||
});
|
||||
|
||||
await renderer.render(chatId);
|
||||
renderer.appendMessage(message, chatId);
|
||||
renderer.updateMessage(msgId, { content: 'Updated' });
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 5. ScrollManager - Управление прокруткой ✅
|
||||
**Файл:** `backend/static/js/managers/scrollManager.js` (346 строк)
|
||||
|
||||
**Функционал:**
|
||||
- ✅ IntersectionObserver для автозагрузки истории
|
||||
- ✅ scrollToBottom() без прыжков (visibility + double RAF)
|
||||
- ✅ scrollToMessage() к конкретному сообщению
|
||||
- ✅ isNearBottom() определение позиции
|
||||
- ✅ saveScrollPosition() / restoreScrollPosition()
|
||||
- ✅ getFirstVisible/LastVisibleMessageId()
|
||||
- ✅ Умное управление observer target
|
||||
|
||||
**API:**
|
||||
```javascript
|
||||
const scrollMgr = new ScrollManager({
|
||||
scrollElement: element,
|
||||
messageLoader: loader,
|
||||
messageRenderer: renderer,
|
||||
messageStore: store,
|
||||
chatId: chatId
|
||||
});
|
||||
|
||||
scrollMgr.init();
|
||||
scrollMgr.scrollToBottom({ instant: true });
|
||||
scrollMgr.scrollToMessage(msgId, { block: 'center' });
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 6. ChatController - Координатор ✅
|
||||
**Файл:** `backend/static/js/controllers/chatController.js` (385 строк)
|
||||
|
||||
**Функционал:**
|
||||
- ✅ Создает и координирует все компоненты
|
||||
- ✅ init() - единая точка инициализации
|
||||
- ✅ Подписывается на Store и WebSocket
|
||||
- ✅ Обрабатывает события (message_added, updated, removed)
|
||||
- ✅ Публичное API для работы с чатом
|
||||
- ✅ Автоматический autoscroll при новых сообщениях
|
||||
- ✅ Incremental rendering
|
||||
|
||||
**Единственная точка входа:**
|
||||
```javascript
|
||||
const chat = new ChatController({
|
||||
chatId: 123,
|
||||
currentUserId: userId,
|
||||
scrollElement: document.getElementById('chatScroll'),
|
||||
wsConnection: userWebSocket
|
||||
});
|
||||
|
||||
await chat.init();
|
||||
|
||||
// Всё работает! Store, Loader, Renderer, ScrollManager
|
||||
chat.sendMessage('Hello!');
|
||||
chat.scrollToBottom();
|
||||
await chat.loadMoreHistory();
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 7. Примеры использования ✅
|
||||
**Файл:** `backend/static/js/examples/chatControllerUsage.js`
|
||||
|
||||
**Демонстрирует:**
|
||||
- ✅ Сравнение старого vs нового подхода
|
||||
- ✅ Инициализация ChatController
|
||||
- ✅ Отправка сообщений
|
||||
- ✅ Прокрутка и навигация
|
||||
- ✅ Интеграция с другими модулями
|
||||
- ✅ Обработка событий
|
||||
|
||||
---
|
||||
|
||||
## ✅ Фаза 2: Интеграция (ЧАСТИЧНО)
|
||||
|
||||
### userWebSocket.js - Событийная архитектура ✅
|
||||
|
||||
**Изменения:**
|
||||
|
||||
1. **handleInitialMessages** - теперь dispatch `ws:initial-messages`:
|
||||
```javascript
|
||||
window.dispatchEvent(new CustomEvent('ws:initial-messages', {
|
||||
detail: { messages, chatId }
|
||||
}));
|
||||
```
|
||||
|
||||
2. **handleNewMessage** - теперь dispatch `ws:new-message`:
|
||||
```javascript
|
||||
window.dispatchEvent(new CustomEvent('ws:new-message', {
|
||||
detail: { message, chatId, isOwnMessage }
|
||||
}));
|
||||
```
|
||||
|
||||
3. **handleMessageUpdated** - dispatch `ws:message-edited`
|
||||
4. **handleMessageDeleted** - dispatch `ws:message-removed`
|
||||
5. **handleReactionAdded** - dispatch `ws:reaction-added`
|
||||
6. **handleReactionRemoved** - dispatch `ws:reaction-removed`
|
||||
|
||||
**Backward Compatibility:**
|
||||
- Старая логика с `messageRenderer` сохранена
|
||||
- Старые события `chat:*` продолжают работать
|
||||
- Код помечен как `[DEPRECATED]` для удаления после миграции
|
||||
|
||||
**Преимущества:**
|
||||
- ✅ Loose coupling - модули независимы
|
||||
- ✅ Event-driven architecture
|
||||
- ✅ Легко тестировать (можно mock события)
|
||||
- ✅ ChatController автоматически получает обновления
|
||||
|
||||
### ChatController.js - Подписка на WS события ✅
|
||||
|
||||
**Обновлено:**
|
||||
|
||||
`_subscribeToWebSocket()` теперь слушает новые события:
|
||||
- `ws:initial-messages` → рендер + scroll
|
||||
- `ws:new-message` → loader.handleNewMessage()
|
||||
- `ws:message-edited` → loader.handleMessageEdited()
|
||||
- `ws:message-removed` → loader.handleMessageRemoved()
|
||||
- `ws:reaction-added` → loader.handleReactionAdded()
|
||||
- `ws:reaction-removed` → loader.handleReactionRemoved()
|
||||
|
||||
Все обработчики проверяют `chatId` чтобы игнорировать события других чатов.
|
||||
|
||||
---
|
||||
|
||||
## 📊 Архитектура
|
||||
|
||||
### Структура файлов:
|
||||
|
||||
```
|
||||
backend/static/js/
|
||||
├── stores/
|
||||
│ ├── messageStore.js ✅ NEW
|
||||
│ └── messageStore.test.js ✅ NEW
|
||||
├── loaders/
|
||||
│ └── messageLoader.js ✅ NEW
|
||||
├── renderers/
|
||||
│ └── messageRendererV2.js ✅ NEW
|
||||
├── managers/
|
||||
│ └── scrollManager.js ✅ NEW
|
||||
├── controllers/
|
||||
│ └── chatController.js ✅ NEW
|
||||
└── examples/
|
||||
└── chatControllerUsage.js ✅ NEW
|
||||
```
|
||||
|
||||
### Поток данных:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ ChatController │
|
||||
│ (единая точка входа) │
|
||||
└────┬─────────────────────────────────┬──────┘
|
||||
│ │
|
||||
▼ ▼
|
||||
┌──────────────┐ ┌──────────────┐
|
||||
│ MessageLoader│◄────────────►│ MessageStore │
|
||||
│ │ │ (Single Truth)│
|
||||
└──────┬───────┘ └───────┬───────┘
|
||||
│ │
|
||||
│ ▼
|
||||
│ ┌──────────────┐
|
||||
│ │MessageRenderer│
|
||||
│ │ V2 │
|
||||
│ └───────┬───────┘
|
||||
│ │
|
||||
▼ ▼
|
||||
┌──────────────┐ ┌──────────────┐
|
||||
│ WebSocket │ │ DOM │
|
||||
└──────────────┘ └──────────────┘
|
||||
▲
|
||||
│
|
||||
┌───────┴──────┐
|
||||
│ScrollManager │
|
||||
└──────────────┘
|
||||
```
|
||||
|
||||
### Преимущества:
|
||||
|
||||
**До рефакторинга:**
|
||||
- ❌ 3 разных пути загрузки
|
||||
- ❌ Дубликаты логики везде
|
||||
- ❌ Прямое манипулирование DOM
|
||||
- ❌ Нет единого состояния
|
||||
- ❌ Сложно тестировать
|
||||
- ❌ Непредсказуемое поведение
|
||||
|
||||
**После рефакторинга:**
|
||||
- ✅ 1 точка входа (ChatController)
|
||||
- ✅ Single Source of Truth (Store)
|
||||
- ✅ Centralized rendering (RendererV2)
|
||||
- ✅ Умный scroll (ScrollManager)
|
||||
- ✅ Легко тестировать
|
||||
- ✅ Предсказуемое поведение
|
||||
|
||||
---
|
||||
|
||||
## 📝 Что осталось сделать
|
||||
|
||||
### Фаза 2: Интеграция (осталось 3 задачи)
|
||||
|
||||
**7. Интеграция с userWebSocket** ✅ DONE
|
||||
- ✅ Переписаны обработчики для dispatch событий `ws:*`
|
||||
- ✅ Сохранена backward compatibility со старым кодом
|
||||
- ✅ ChatController подписан на новые события
|
||||
- ✅ Создана документация [WEBSOCKET_INTEGRATION.md](../docs/guides/WEBSOCKET_INTEGRATION.md)
|
||||
|
||||
**8. Удаление старого кода** ⏳ PENDING
|
||||
- Удалить chatHistoryLoader.js
|
||||
- Удалить старый messageRenderer.js
|
||||
- Cleanup неиспользуемых функций
|
||||
|
||||
**9. Обновление chatDetail.js** ⏳ NEXT
|
||||
- Заменить инициализацию на ChatController
|
||||
- Удалить старые вызовы модулей
|
||||
- Упростить код в 10 раз
|
||||
|
||||
**10. Тестирование** ⏳ PENDING
|
||||
- Мануальное тестирование всех сценариев
|
||||
- Проверка scroll jumps (должны исчезнуть)
|
||||
- Проверка day-dividers (единый стиль)
|
||||
- Регрессионное тестирование
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Следующие шаги
|
||||
|
||||
### Сейчас можно:
|
||||
|
||||
**Опция A:** Протестировать базовую архитектуру
|
||||
```javascript
|
||||
// Загрузить тесты в консоли
|
||||
<script type="module" src="/static/js/stores/messageStore.test.js"></script>
|
||||
runMessageStoreTests()
|
||||
```
|
||||
|
||||
**Опция B:** Продолжить интеграцию
|
||||
- Переписать userWebSocket для работы с ChatController
|
||||
- Обновить chatDetail.js
|
||||
- Удалить старый код
|
||||
|
||||
**Опция C:** Остановиться на сегодня
|
||||
- Базовая архитектура готова
|
||||
- Можно продолжить завтра
|
||||
- Код уже коммитить не страшно
|
||||
|
||||
---
|
||||
|
||||
## 💡 Рекомендация
|
||||
|
||||
**Я рекомендую Опцию C** - остановиться на сегодня:
|
||||
|
||||
✅ Создано 6 новых модулей (1780+ строк нового кода)
|
||||
✅ Архитектура полностью спроектирована
|
||||
✅ Тесты написаны
|
||||
✅ Примеры готовы
|
||||
|
||||
Завтра свежим взглядом:
|
||||
1. Интегрируем с существующим кодом
|
||||
2. Тестируем
|
||||
3. Удаляем старое
|
||||
4. Радуемся результату 🎉
|
||||
|
||||
**Что скажешь?**
|
||||
504
docs/reports/CHAT_STYLES_HISTORY_ANALYSIS.md
Normal file
504
docs/reports/CHAT_STYLES_HISTORY_ANALYSIS.md
Normal file
@ -0,0 +1,504 @@
|
||||
# История изменений стилей чата: Полный анализ
|
||||
|
||||
**Дата анализа:** 12.01.2026
|
||||
**Проблема:** Подергивание скролла при загрузке истории
|
||||
|
||||
## 🔍 Обнаруженное: КРИТИЧЕСКИЙ РЕФАКТОРИНГ 27 ноября 2025
|
||||
|
||||
### Коммит 64e23e3: "Redesign: современный интерфейс чата в стиле мессенджеров"
|
||||
|
||||
**Дата:** 27 ноября 2025
|
||||
**Масштаб:** +241 строка, -207 строк в `chat-detail.css`
|
||||
|
||||
---
|
||||
|
||||
## 📊 ДО vs ПОСЛЕ: Сравнение архитектуры
|
||||
|
||||
### ❌ ДО редизайна (64e23e3~1)
|
||||
|
||||
**Структура HTML:**
|
||||
```html
|
||||
<main class="main">
|
||||
<div class="chat-root">
|
||||
<div class="row">
|
||||
<div class="chat-col">
|
||||
<div class="feed-header"></div>
|
||||
<div class="feed-card chat-box">
|
||||
<div class="chat-scroll">
|
||||
<!-- Сообщения -->
|
||||
</div>
|
||||
<div class="chat-composer"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
```
|
||||
|
||||
**Ключевые CSS:**
|
||||
```css
|
||||
/* Старая структура (простая) */
|
||||
.chat-root > .row {
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.chat-col {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 93%; /* ❌ ПРОБЛЕМА: фиксированный процент */
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.feed-card.chat-box {
|
||||
position: relative;
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
display: grid;
|
||||
grid-template-rows: 1fr auto; /* ✅ ХОРОШИЙ: 1fr для прокрутки */
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Простой контейнер скролла */
|
||||
.chat-scroll {
|
||||
overflow-y: auto;
|
||||
padding: var(--space-2);
|
||||
min-height: 0;
|
||||
scroll-behavior: smooth;
|
||||
/* ✅ НЕТ CSS containment! */
|
||||
}
|
||||
|
||||
/* Простые пузырьки сообщений */
|
||||
.bubble {
|
||||
border-radius: 16px;
|
||||
padding: var(--space-1) var(--space-2);
|
||||
word-break: break-word;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, .06);
|
||||
}
|
||||
|
||||
.bubble-me {
|
||||
background: color-mix(in srgb, var(--bs-primary) 92%, #fff 0%);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.bubble-other {
|
||||
background: var(--bs-secondary-bg);
|
||||
color: var(--bs-body-color);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### ✅ ПОСЛЕ редизайна (текущее состояние)
|
||||
|
||||
**Новая структура HTML:**
|
||||
```html
|
||||
<body class="has-chat-page"> <!-- ❌ Добавлен класс на body -->
|
||||
<main> <!-- ❌ overflow: hidden -->
|
||||
<div class="chat-page"> <!-- ❌ calc(100vh - navbar) -->
|
||||
<div class="chat-header"></div>
|
||||
<div class="chat-container"> <!-- ❌ Новый wrapper -->
|
||||
<div class="chat-messages-scroll"> <!-- ❗ ИЗМЕНЕН КЛАСС -->
|
||||
<!-- Сообщения -->
|
||||
</div>
|
||||
<div class="chat-typing-indicator"></div>
|
||||
<div class="chat-composer-wrapper"></div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</body>
|
||||
```
|
||||
|
||||
**Новые CSS (проблемные):**
|
||||
```css
|
||||
/* КРИТИЧНО: Новая агрессивная архитектура */
|
||||
body:has(.chat-page) {
|
||||
overflow: hidden !important; /* ❌ Убрали скролл body */
|
||||
padding-bottom: 0 !important;
|
||||
}
|
||||
|
||||
body:has(.chat-page) main {
|
||||
overflow: hidden !important; /* ❌ Убрали скролл main */
|
||||
padding: 0 !important;
|
||||
margin: 0 !important;
|
||||
}
|
||||
|
||||
.chat-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: calc(100vh - var(--navbar-h)); /* ❌ Жесткая высота */
|
||||
width: 100%;
|
||||
overflow: hidden; /* ❌ Еще один overflow: hidden */
|
||||
}
|
||||
|
||||
/* ПРОБЛЕМА: Новый контейнер скролла с containment */
|
||||
.chat-messages-scroll {
|
||||
flex: 1; /* ❌ Теперь flex вместо grid 1fr */
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
padding: var(--space-3);
|
||||
scroll-behavior: smooth;
|
||||
|
||||
/* ❗ TELEGRAM OPTIMIZATIONS - НОВЫЕ В ЯНВАРЕ 2026 */
|
||||
contain: layout style paint; /* ⚠️ Может блокировать scroll */
|
||||
will-change: scroll-position;
|
||||
transform: translateZ(0); /* GPU acceleration */
|
||||
backface-visibility: hidden;
|
||||
perspective: 1000px;
|
||||
}
|
||||
|
||||
/* Десктопная обертка (НОВАЯ) */
|
||||
@media (min-width: 768px) {
|
||||
.chat-container {
|
||||
border-radius: var(--bs-border-radius-lg);
|
||||
border: 1px solid var(--bs-border-color);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
}
|
||||
|
||||
/* Пузырьки сейчас определены ДВАЖДЫ! */
|
||||
/* 1. В chat-detail.css (строки 432-451) */
|
||||
.bubble {
|
||||
/* ... то же самое ... */
|
||||
}
|
||||
|
||||
/* 2. В bootstrap-custom.css (минифицировано) */
|
||||
.bubble{border-radius:16px;padding:var(--space-1) var(--space-2);...}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🚨 ПРОБЛЕМЫ новой архитектуры
|
||||
|
||||
### 1. Множественные `overflow: hidden` ❌
|
||||
```css
|
||||
body:has(.chat-page) { overflow: hidden !important; }
|
||||
body:has(.chat-page) main { overflow: hidden !important; }
|
||||
.chat-page { overflow: hidden; }
|
||||
```
|
||||
**Последствия:**
|
||||
- Тройная вложенность контроля скролла
|
||||
- Возможные конфликты при prepend сообщений
|
||||
- Усложненная иерархия для браузера
|
||||
|
||||
### 2. Жесткая высота вместо flexbox ❌
|
||||
```css
|
||||
/* СТАРОЕ (хорошее) */
|
||||
.feed-card.chat-box {
|
||||
display: grid;
|
||||
grid-template-rows: 1fr auto; /* Контент растягивается */
|
||||
}
|
||||
|
||||
/* НОВОЕ (проблемное) */
|
||||
.chat-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: calc(100vh - var(--navbar-h)); /* Жесткая высота */
|
||||
}
|
||||
|
||||
.chat-messages-scroll {
|
||||
flex: 1; /* Относительно родителя с фиксированной высотой */
|
||||
}
|
||||
```
|
||||
**Последствия:**
|
||||
- При prepend старых сообщений высота родителя не пересчитывается
|
||||
- `flex: 1` зависит от calc(100vh), что может вызывать jump
|
||||
- Grid `1fr` был более стабилен
|
||||
|
||||
### 3. CSS Containment конфликты ⚠️
|
||||
```css
|
||||
.chat-messages-scroll {
|
||||
contain: layout style paint; /* ❗ ДОБАВЛЕНО В ЯНВАРЕ 2026 */
|
||||
will-change: scroll-position;
|
||||
transform: translateZ(0);
|
||||
}
|
||||
```
|
||||
**Почему это проблема:**
|
||||
- `contain: layout` изолирует layout calculations
|
||||
- При prepend высота родителя может НЕ обновляться
|
||||
- `will-change` и `transform` создают новый stacking context
|
||||
- Это может БЛОКИРОВАТЬ правильное восстановление scrollTop
|
||||
|
||||
### 4. Дублирование стилей `.bubble` ❌
|
||||
**Найдено в:**
|
||||
- `backend/static/css/components/chat-detail.css` (строки 432-451)
|
||||
- `backend/static/css/bootstrap-custom.css` (минифицировано)
|
||||
|
||||
**Последствия:**
|
||||
- Непонятно какие стили применяются
|
||||
- Возможные конфликты specificity
|
||||
- Усложненная отладка
|
||||
|
||||
### 5. Смена классов контейнера ❌
|
||||
```html
|
||||
<!-- СТАРОЕ -->
|
||||
<div class="chat-scroll"> <!-- Стабильный класс -->
|
||||
|
||||
<!-- НОВОЕ -->
|
||||
<div class="chat-messages-scroll"> <!-- Новый класс -->
|
||||
```
|
||||
**Последствия:**
|
||||
- JavaScript код может искать `.chat-scroll` вместо `.chat-messages-scroll`
|
||||
- Старые селекторы больше не работают
|
||||
- Конфликт между разными стилями
|
||||
|
||||
---
|
||||
|
||||
## 💡 КОРЕНЬ ПРОБЛЕМЫ подергивания
|
||||
|
||||
### До редизайна (работало хорошо):
|
||||
```
|
||||
body → main → .chat-root → .row → .chat-col
|
||||
↓
|
||||
.feed-card.chat-box (grid 1fr auto)
|
||||
↓
|
||||
.chat-scroll (overflow-y: auto)
|
||||
↓
|
||||
Сообщения (простые .bubble)
|
||||
```
|
||||
|
||||
**Почему работало:**
|
||||
1. ✅ Grid `1fr` автоматически подстраивался под контент
|
||||
2. ✅ НЕТ `overflow: hidden` на родителях
|
||||
3. ✅ НЕТ CSS containment
|
||||
4. ✅ Прямая связь между scrollHeight и контентом
|
||||
|
||||
### После редизайна (подергивание):
|
||||
```
|
||||
body (overflow: hidden!) → main (overflow: hidden!)
|
||||
↓
|
||||
.chat-page (height: calc(100vh), overflow: hidden)
|
||||
↓
|
||||
.chat-container (с border и box-shadow)
|
||||
↓
|
||||
.chat-messages-scroll (flex: 1, contain: layout)
|
||||
↓
|
||||
Сообщения (те же .bubble)
|
||||
```
|
||||
|
||||
**Почему подергивание:**
|
||||
1. ❌ `flex: 1` относительно жесткой высоты `calc(100vh)`
|
||||
2. ❌ `contain: layout` изолирует layout → высота родителя НЕ обновляется сразу
|
||||
3. ❌ Тройной `overflow: hidden` → browser долго пересчитывает
|
||||
4. ❌ GPU acceleration (`translateZ`, `will-change`) → новый rendering layer
|
||||
5. ❌ При prepend: контент добавляется → layout пересчет → scroll adjustment → visual jump
|
||||
|
||||
---
|
||||
|
||||
## 🔧 РЕШЕНИЯ
|
||||
|
||||
### Решение 1: Убрать CSS Containment (ВРЕМЕННО) ⚡
|
||||
```css
|
||||
.chat-messages-scroll {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: var(--space-3);
|
||||
scroll-behavior: smooth;
|
||||
|
||||
/* ❌ УБРАТЬ ЭТИ СТРОКИ */
|
||||
/* contain: layout style paint; */
|
||||
/* will-change: scroll-position; */
|
||||
/* transform: translateZ(0); */
|
||||
/* backface-visibility: hidden; */
|
||||
/* perspective: 1000px; */
|
||||
}
|
||||
```
|
||||
|
||||
**Плюсы:**
|
||||
- Должно убрать подергивание СРАЗУ
|
||||
- Простое решение для тестирования
|
||||
|
||||
**Минусы:**
|
||||
- Потеряем Telegram-оптимизации
|
||||
- Scroll может быть менее плавным
|
||||
|
||||
---
|
||||
|
||||
### Решение 2: Вернуться к Grid Layout ✅ (РЕКОМЕНДУЕТСЯ)
|
||||
```css
|
||||
/* Убрать жесткую высоту */
|
||||
.chat-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
/* height: calc(100vh - var(--navbar-h)); ❌ УБРАТЬ */
|
||||
min-height: calc(100vh - var(--navbar-h)); /* ✅ Минимум, но может расти */
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Вернуть grid для chat-container */
|
||||
.chat-container {
|
||||
display: grid; /* ✅ ВМЕСТО flex */
|
||||
grid-template-rows: 1fr auto auto; /* messages / typing / composer */
|
||||
min-height: 0;
|
||||
flex: 1; /* Растягивается внутри .chat-page */
|
||||
}
|
||||
|
||||
.chat-messages-scroll {
|
||||
/* flex: 1; ❌ УБРАТЬ */
|
||||
overflow-y: auto;
|
||||
min-height: 0; /* ✅ Для grid child */
|
||||
|
||||
/* Оставить только базовые Telegram-оптимизации */
|
||||
contain: style paint; /* ✅ НЕ layout! */
|
||||
transform: translateZ(0); /* GPU acceleration */
|
||||
}
|
||||
```
|
||||
|
||||
**Плюсы:**
|
||||
- Grid `1fr` естественно подстраивается под контент
|
||||
- `min-height: 0` для grid child - правильная практика
|
||||
- `contain: style paint` без `layout` - безопаснее
|
||||
|
||||
**Минусы:**
|
||||
- Требует больше тестирования
|
||||
|
||||
---
|
||||
|
||||
### Решение 3: Убрать лишние `overflow: hidden` ✅
|
||||
```css
|
||||
/* ❌ УБРАТЬ или сделать условным */
|
||||
/* body:has(.chat-page) {
|
||||
overflow: hidden !important;
|
||||
} */
|
||||
|
||||
/* body:has(.chat-page) main {
|
||||
overflow: hidden !important;
|
||||
} */
|
||||
|
||||
/* Достаточно одного на .chat-page */
|
||||
.chat-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: calc(100vh - var(--navbar-h));
|
||||
overflow: hidden; /* ✅ Только здесь */
|
||||
}
|
||||
```
|
||||
|
||||
**Плюсы:**
|
||||
- Упрощает иерархию
|
||||
- Меньше пересчетов layout
|
||||
|
||||
---
|
||||
|
||||
### Решение 4: Отключить `scroll-behavior: smooth` во время prepend ✅
|
||||
```javascript
|
||||
// В scrollManager.js, loadMoreHistory():
|
||||
const scrollEl = this.scrollEl;
|
||||
|
||||
// Сохраняем старое значение
|
||||
const oldBehavior = scrollEl.style.scrollBehavior;
|
||||
|
||||
// Отключаем smooth во время prepend
|
||||
scrollEl.style.scrollBehavior = 'auto';
|
||||
|
||||
// ... prepend логика ...
|
||||
|
||||
// Восстанавливаем
|
||||
scrollEl.style.scrollBehavior = oldBehavior || 'smooth';
|
||||
```
|
||||
|
||||
**Плюсы:**
|
||||
- Простое добавление к существующему коду
|
||||
- Убирает плавную анимацию во время adjustment
|
||||
|
||||
---
|
||||
|
||||
### Решение 5: Комбинированное (ЛУЧШЕЕ) 🏆
|
||||
```css
|
||||
/* 1. Убрать лишние overflow: hidden */
|
||||
/* body:has(.chat-page) { ... } ❌ УБРАТЬ */
|
||||
/* body:has(.chat-page) main { ... } ❌ УБРАТЬ */
|
||||
|
||||
/* 2. Grid вместо flex + жесткой высоты */
|
||||
.chat-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: calc(100vh - var(--navbar-h)); /* ✅ min вместо height */
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.chat-container {
|
||||
display: grid; /* ✅ Grid */
|
||||
grid-template-rows: 1fr auto auto;
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* 3. Безопасный CSS containment */
|
||||
.chat-messages-scroll {
|
||||
overflow-y: auto;
|
||||
min-height: 0;
|
||||
padding: var(--space-3);
|
||||
scroll-behavior: smooth;
|
||||
|
||||
/* Только безопасные оптимизации */
|
||||
contain: style paint; /* ✅ БЕЗ layout! */
|
||||
transform: translateZ(0);
|
||||
backface-visibility: hidden;
|
||||
}
|
||||
```
|
||||
|
||||
```javascript
|
||||
// 4. JavaScript: отключить smooth во время prepend
|
||||
scrollEl.style.scrollBehavior = 'auto';
|
||||
// ... prepend ...
|
||||
scrollEl.style.scrollBehavior = 'smooth';
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📈 ПРИОРИТЕТЫ ИСПРАВЛЕНИЯ
|
||||
|
||||
### Уровень 1 (КРИТИЧНО - СДЕЛАТЬ СЕЙЧАС): 🔴
|
||||
1. ✅ **ГОТОВО:** Отключить `scroll-behavior` во время prepend (JavaScript)
|
||||
2. ✅ **ГОТОВО:** Увеличить rootMargin до 300px (раньше грузить историю)
|
||||
3. ✅ **ГОТОВО:** Увеличить limit до 50 сообщений
|
||||
|
||||
### Уровень 2 (ВАЖНО - ЗАВТРА): 🟡
|
||||
4. ⏳ Изменить `contain: layout style paint` → `contain: style paint` (убрать layout)
|
||||
5. ⏳ Заменить `height: calc(100vh)` на `min-height: calc(100vh)` в `.chat-page`
|
||||
6. ⏳ Добавить `min-height: 0` в `.chat-messages-scroll`
|
||||
|
||||
### Уровень 3 (ЖЕЛАТЕЛЬНО - НА ВЫХОДНЫХ): 🟢
|
||||
7. ⏳ Переписать `.chat-container` на Grid вместо Flex
|
||||
8. ⏳ Убрать лишние `overflow: hidden` на body и main
|
||||
9. ⏳ Удалить дублирующие стили `.bubble` из `bootstrap-custom.css`
|
||||
|
||||
---
|
||||
|
||||
## 📝 ВЫВОДЫ
|
||||
|
||||
1. **Причина подергивания найдена:**
|
||||
- Редизайн 27 ноября 2025 изменил всю архитектуру layout
|
||||
- Добавлена сложная система `overflow: hidden`
|
||||
- Добавлен агрессивный CSS containment
|
||||
- Жесткая высота вместо flex-grow
|
||||
|
||||
2. **Telegram-оптимизации (январь 2026) усугубили проблему:**
|
||||
- `contain: layout` блокирует правильный пересчет высоты
|
||||
- GPU acceleration создает новый rendering layer
|
||||
- Double RAF помогает, но не решает root cause
|
||||
|
||||
3. **Лучшее решение:**
|
||||
- Комбинация изменений CSS (Grid, min-height, contain без layout)
|
||||
- JavaScript workaround (отключение smooth scroll)
|
||||
- Ранняя загрузка истории (300px rootMargin, 50 messages)
|
||||
|
||||
---
|
||||
|
||||
## 🎯 СЛЕДУЮЩИЕ ШАГИ
|
||||
|
||||
**Сегодня:**
|
||||
- [x] rootMargin 300px
|
||||
- [x] limit 50 messages
|
||||
- [x] scroll-behavior: auto
|
||||
|
||||
**Завтра:**
|
||||
- [ ] Протестировать текущие изменения
|
||||
- [ ] Если подергивание осталось → применить CSS fixes (Уровень 2)
|
||||
- [ ] Если всё ОК → оставить как есть
|
||||
|
||||
**На выходных:**
|
||||
- [ ] Полная переработка layout на Grid (Уровень 3)
|
||||
- [ ] Удаление дублирующих стилей
|
||||
- [ ] Документирование финальной архитектуры
|
||||
287
docs/reports/CHAT_TESTS_DATA_FORMAT_ANALYSIS.md
Normal file
287
docs/reports/CHAT_TESTS_DATA_FORMAT_ANALYSIS.md
Normal file
@ -0,0 +1,287 @@
|
||||
# Анализ формата данных в тестах чата
|
||||
|
||||
**Дата:** 12.01.2026
|
||||
**Статус:** Требуется обновление
|
||||
|
||||
## Проблема
|
||||
|
||||
Текущие тесты используют **упрощённую структуру данных**, которая не соответствует реальным данным, приходящим с бэкэнда.
|
||||
|
||||
### Текущий формат в тестах (устаревший):
|
||||
|
||||
```javascript
|
||||
{
|
||||
id: 100,
|
||||
chat_id: 1,
|
||||
sender: { id: 1, username: 'user1' },
|
||||
content: 'Test message',
|
||||
created_at: '2024-01-01T10:00:00Z' // ISO строка
|
||||
}
|
||||
```
|
||||
|
||||
### Реальный формат с бэкэнда (из `serialize_message()`):
|
||||
|
||||
```javascript
|
||||
{
|
||||
// Основные поля
|
||||
id: 681,
|
||||
content: "Текст сообщения",
|
||||
author_id: 1,
|
||||
author_name: "Самый главный admin",
|
||||
author_url: "/employees/1/",
|
||||
avatar: "/media/users/avatars/avatar_1.jpg",
|
||||
created: "12.01.2026 16:00",
|
||||
created_ts: 1736688000000, // Миллисекунды
|
||||
|
||||
// Флаги состояния
|
||||
is_edited: false,
|
||||
edited_at: null,
|
||||
is_deleted: false,
|
||||
is_pinned: false,
|
||||
is_forwarded: false,
|
||||
is_system: false,
|
||||
has_attachments: false,
|
||||
|
||||
// Реакции
|
||||
reactions_summary: {
|
||||
'👍': {
|
||||
count: 3,
|
||||
users: [1, 2, 3],
|
||||
user_names: ['User1', 'User2', 'User3']
|
||||
}
|
||||
},
|
||||
|
||||
// Ответ на сообщение
|
||||
reply_to: {
|
||||
id: 621,
|
||||
content: "Текст оригинального сообщения...",
|
||||
author_name: "Самый главный admin"
|
||||
},
|
||||
|
||||
// Вложения
|
||||
attachments: [
|
||||
{
|
||||
id: 1,
|
||||
file_name: "document.pdf",
|
||||
file_type: "application/pdf",
|
||||
file_url: "/media/attachments/document.pdf",
|
||||
file_size: 1024000,
|
||||
mime_type: "application/pdf",
|
||||
thumbnail: null
|
||||
}
|
||||
],
|
||||
|
||||
// Пересылка
|
||||
forwarded_from: {
|
||||
author_id: 2,
|
||||
author_name: "Другой пользователь",
|
||||
message_id: 500,
|
||||
created_at: "11.01.2026 15:00",
|
||||
created_ts: 1736601600000,
|
||||
chat_name: "Другой чат"
|
||||
},
|
||||
|
||||
// Голосование
|
||||
poll: {
|
||||
id: 1,
|
||||
question: "Вопрос?",
|
||||
is_anonymous: false,
|
||||
is_multiple_choice: false,
|
||||
is_quiz: false,
|
||||
is_closed: false,
|
||||
closes_at: null,
|
||||
total_voters: 5,
|
||||
options: [
|
||||
{
|
||||
id: 1,
|
||||
text: "Вариант 1",
|
||||
position: 0,
|
||||
vote_count: 3,
|
||||
percentage: 0
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Ключевые различия
|
||||
|
||||
| Поле | Тесты (старое) | Бэкэнд (реальное) | Проблема |
|
||||
|------|----------------|-------------------|----------|
|
||||
| `sender` | `{ id, username }` | ❌ Не существует | Тесты используют несуществующее поле |
|
||||
| `author_id` | ❌ Отсутствует | ✅ Присутствует | Критично для работы |
|
||||
| `author_name` | ❌ Отсутствует | ✅ Присутствует | Критично для отображения |
|
||||
| `created_at` | ISO строка | ❌ Не используется | Конвертируется в `created_ts` |
|
||||
| `created_ts` | ❌ Отсутствует | Миллисекунды | Критично для сортировки |
|
||||
| `is_edited`, `is_deleted`, etc. | ❌ Отсутствуют | ✅ Присутствуют | Важно для функциональности |
|
||||
| `reactions_summary` | ❌ Отсутствует | ✅ Присутствует | Реакции не тестируются |
|
||||
| `reply_to` | ❌ Отсутствует | ✅ Присутствует | Ответы не тестируются |
|
||||
| `attachments` | ❌ Отсутствует | ✅ Присутствует | Вложения не тестируются |
|
||||
|
||||
## Логи реальных запросов
|
||||
|
||||
Из бэкэнд логов видно реальные API вызовы:
|
||||
|
||||
```
|
||||
[INFO] GET /api/v1/communications/chats/10/messages/around/?around_id=1768222792363&limit=20
|
||||
[INFO] GET /api/v1/communications/chats/10/messages/?before_id=681&limit=20
|
||||
[INFO] GET /api/v1/communications/chats/10/messages/?before_id=661&limit=20
|
||||
[DEBUG] serialize_message: added reply_to id=621, author=Самый главный admin
|
||||
```
|
||||
|
||||
**Особенности:**
|
||||
- Используется `around_id` для начальной загрузки вокруг сообщения
|
||||
- Параметр `before_id` для дозагрузки истории
|
||||
- Limit по умолчанию = 20 сообщений
|
||||
- Реальные ID сообщений: 681, 661, 641, 612, 527, 506, 477, 453
|
||||
- Сообщения содержат `reply_to` с полной информацией
|
||||
|
||||
## Решение
|
||||
|
||||
### 1. ✅ Создана функция `createRealisticMessage()`
|
||||
|
||||
```javascript
|
||||
createRealisticMessage(overrides = {}) {
|
||||
const defaults = {
|
||||
id: Math.floor(Math.random() * 10000),
|
||||
content: 'Тестовое сообщение',
|
||||
author_id: 1,
|
||||
author_name: 'Тестовый пользователь',
|
||||
author_url: '/employees/1/',
|
||||
avatar: '/media/users/avatars/default.jpg',
|
||||
created: '12.01.2026 16:00',
|
||||
created_ts: Date.now(),
|
||||
is_edited: false,
|
||||
edited_at: null,
|
||||
is_deleted: false,
|
||||
is_pinned: false,
|
||||
is_forwarded: false,
|
||||
is_system: false,
|
||||
has_attachments: false,
|
||||
reactions_summary: {},
|
||||
reply_to: null,
|
||||
forwarded_from: null,
|
||||
attachments: [],
|
||||
poll: null
|
||||
};
|
||||
return { ...defaults, ...overrides };
|
||||
}
|
||||
```
|
||||
|
||||
### 2. ⏳ Обновление тестов (в процессе)
|
||||
|
||||
**Обновлено:**
|
||||
- ✅ `testMessageStore()` - тесты 1-10 обновлены с реалистичными данными
|
||||
- ✅ Добавлены тесты для `reply_to`, `attachments`, `reactions_summary`
|
||||
|
||||
**Требуется обновить:**
|
||||
- ⏳ `testMessageStore()` - тесты 11-23 (оптимистичные сообщения, события, сортировка, дублика ты)
|
||||
- ⏳ `testMessageLoader()` - все 20 тестов
|
||||
- ⏳ `testScrollManager()` - все 10 тестов
|
||||
- ⏳ `testMessageRenderer()` - все 8 тестов
|
||||
- ⏳ `testChatController()` - все 11 тестов
|
||||
- ⏳ `testIntegration()` - все 8 тестов
|
||||
- ⏳ `testPerformance()` - все 5 тестов
|
||||
- ⏳ `testAdvanced()` - все 23 теста (особенно важно)
|
||||
- ⏳ `testScrollStability()` - все 8 тестов (критично)
|
||||
|
||||
### 3. Дополнительные тесты для реальных данных
|
||||
|
||||
Необходимо добавить тесты для:
|
||||
|
||||
```javascript
|
||||
// Тест reply_to в реальном формате
|
||||
const msgWithReply = this.createRealisticMessage({
|
||||
id: 682,
|
||||
content: 'Ответ на предыдущее',
|
||||
reply_to: {
|
||||
id: 681,
|
||||
content: 'Оригинальное сообщение...',
|
||||
author_name: 'Другой пользователь'
|
||||
}
|
||||
});
|
||||
|
||||
// Тест вложений в реальном формате
|
||||
const msgWithAttachments = this.createRealisticMessage({
|
||||
id: 683,
|
||||
has_attachments: true,
|
||||
attachments: [
|
||||
{
|
||||
id: 1,
|
||||
file_name: 'report.pdf',
|
||||
file_type: 'application/pdf',
|
||||
file_url: '/media/attachments/report.pdf',
|
||||
file_size: 2048576,
|
||||
mime_type: 'application/pdf',
|
||||
thumbnail: null
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
// Тест реакций в реальном формате
|
||||
const msgWithReactions = this.createRealisticMessage({
|
||||
id: 684,
|
||||
reactions_summary: {
|
||||
'👍': { count: 5, users: [1, 2, 3, 4, 5], user_names: ['User1', 'User2', ...] },
|
||||
'❤️': { count: 2, users: [1, 3], user_names: ['User1', 'User3'] }
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
## Критичность
|
||||
|
||||
🔴 **ВЫСОКАЯ КРИТИЧНОСТЬ**
|
||||
|
||||
**Почему:**
|
||||
1. Тесты сейчас **НЕ проверяют реальную работу** системы
|
||||
2. Код может работать с тестовыми данными, но **падать на реальных**
|
||||
3. Важные функции не тестируются: реакции, ответы, вложения
|
||||
4. `created_ts` критичен для сортировки и отображения дат
|
||||
|
||||
## Следующие шаги
|
||||
|
||||
1. ✅ Создана `createRealisticMessage()`
|
||||
2. ⏳ Обновить все 113 тестов на использование реального формата
|
||||
3. ⏳ Добавить тесты для API endpoint'ов (`/around/`, `/before_id/`)
|
||||
4. ⏳ Добавить тесты для edge cases с реальными данными
|
||||
5. ⏳ Протестировать на реальных данных из production
|
||||
|
||||
## Примеры обновления
|
||||
|
||||
### До (неправильно):
|
||||
|
||||
```javascript
|
||||
const msg = {
|
||||
id: 100,
|
||||
chat_id: 1,
|
||||
sender: { id: 1, username: 'user1' },
|
||||
content: 'Test',
|
||||
created_at: '2024-01-01T10:00:00Z'
|
||||
};
|
||||
```
|
||||
|
||||
### После (правильно):
|
||||
|
||||
```javascript
|
||||
const msg = this.createRealisticMessage({
|
||||
id: 100,
|
||||
chat_id: 1,
|
||||
author_id: 1,
|
||||
author_name: 'Иван Иванов',
|
||||
content: 'Тест',
|
||||
created_ts: new Date('2024-01-01T10:00:00Z').getTime()
|
||||
});
|
||||
```
|
||||
|
||||
## Статистика
|
||||
|
||||
- **Всего мест с `created_at:`** 36
|
||||
- **Всего мест с `sender:`** ~100+
|
||||
- **Обновлено:** ~10
|
||||
- **Осталось обновить:** ~126
|
||||
|
||||
## Вывод
|
||||
|
||||
Тесты требуют **масштабного обновления** для соответствия реальным данным с бэкэнда. Текущая версия тестов проверяет работу с упрощённым форматом, который не используется в production.
|
||||
|
||||
**Рекомендация:** Продолжить обновление всех тестов с использованием `createRealisticMessage()` для полного соответствия реальным данным.
|
||||
Loading…
x
Reference in New Issue
Block a user