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:
Игорь Надеин 2026-01-12 17:21:41 +03:00
parent 1efbe8cd12
commit 6d76988cdd
40 changed files with 16542 additions and 255 deletions

View File

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

View File

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

View File

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

View 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()` прямо сейчас.

View 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)

View 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

View 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

View 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

View 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 (не все тесты пройдены)

View File

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

View 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'
]
};

View File

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

View File

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

View File

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

View File

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

View File

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

View 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
};

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

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

View 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();
}
}

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

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

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

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

View File

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

View 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();
}
})();

View 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 = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#039;'
};
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;

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

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

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

View 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)

File diff suppressed because it is too large Load Diff

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

File diff suppressed because it is too large Load Diff

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

View 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 = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#039;'
};
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
};

View File

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

View 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. Радуемся результату 🎉
**Что скажешь?**

View 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)
- [ ] Удаление дублирующих стилей
- [ ] Документирование финальной архитектуры

View 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()` для полного соответствия реальным данным.