refactor(chat): удалены неиспользуемые модули загрузки сообщений

- Удалён chatWebSocket.js (дублировал userWebSocket.js)
- Удалён initialMessagesLoader.js (не используется, загрузка через WS)
- Очищены импорты и закомментированный код в chatDetail.js
- Добавлен план рефакторинга в docs/in-progress/

Загрузка initial messages теперь происходит только через:
- userWebSocket.handleInitialMessages() - начальные сообщения
- chatHistoryLoader - подгрузка истории при скролле
This commit is contained in:
Игорь Надеин 2026-01-12 10:27:38 +03:00
parent 85ce134bac
commit 5906bbaf86
5 changed files with 229 additions and 542 deletions

View File

@ -1,407 +0,0 @@
/**
* @fileoverview Chat WebSocket - WebSocket соединение для чата в реальном времени
* Отправка/получение сообщений, индикация "печатает...", рендеринг в DOM
* @module components/chatWebSocket
*/
import { getChatAvatar } from './chatAvatarMap.js';
import {
createMessageElement,
createDayDivider,
formatDay,
toTimestamp
} from './chatMessageTemplates.js';
/**
* Инициализирует WebSocket соединение для чата
* @param {Object} options - Опции инициализации
* @param {number} options.chatId - ID чата
* @param {number} options.meId - ID текущего пользователя
* @param {string} [options.scrollContainerId='chatScroll'] - ID контейнера скролла
* @param {string} [options.formId='chatForm'] - ID формы отправки
* @param {string} [options.textareaId='id_content'] - ID textarea
* @param {string} [options.typingIndicatorId='typing'] - ID индикатора "печатает..."
* @param {string} [options.profileUrl] - URL профиля текущего пользователя
* @param {string} [options.detailUrlTemplate] - Шаблон URL деталей сотрудника
* @param {Object} [options.avatarMap] - Карта аватаров пользователей
* @param {Object} [options.markReadApi] - API из chatMarkRead для интеграции
* @returns {Object|null} API WebSocket или null если параметры невалидны
*/
// Храним активное соединение для предотвращения дубликатов
let activeWebSocket = null;
let lastOptions = null;
export function initChatWebSocket(options = {}) {
const {
chatId,
meId,
scrollContainerId = 'chatScroll',
formId = 'chatForm',
textareaId = 'id_content',
typingIndicatorId = 'typing',
profileUrl = '/employees/profile/',
detailUrlTemplate = '/employees/0/',
avatarMap = {},
markReadApi,
bindFormSubmit = true
} = options;
// Проверка обязательных параметров
if (!chatId || !meId) {
console.warn('ChatWebSocket: chatId and meId are required');
return null;
}
// Закрываем существующее соединение перед созданием нового
if (activeWebSocket && activeWebSocket.readyState !== WebSocket.CLOSED) {
console.log('ChatWebSocket: closing existing connection');
activeWebSocket.close(1000, 'Reconnecting');
}
// Сохраняем опции для переподключения
lastOptions = options;
// Получение элементов
const scrollEl = document.getElementById(scrollContainerId);
if (!scrollEl) {
console.warn(`ChatWebSocket: element #${scrollContainerId} not found`);
return null;
}
const form = document.getElementById(formId);
const ta = document.getElementById(textareaId);
const typingEl = document.getElementById(typingIndicatorId);
// Извлекаем методы из chatMarkRead API
const { markRead, observeLastForeign, autoscroll, atBottom } = markReadApi || {};
// Создание WebSocket соединения
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
const wsUrl = `${proto}://${location.host}/ws/chat/${chatId}/`;
const ws = new WebSocket(wsUrl);
// Сохраняем активное соединение
activeWebSocket = ws;
// Настройки переподключения
let reconnectAttempts = 0;
const maxReconnectAttempts = 5;
const reconnectDelay = 3000;
/**
* Инициализирует последний день из DOM
*/
function ensureLastDayFromDom() {
const dl = scrollEl.querySelector('.day-divider:last-of-type span');
if (dl) {
scrollEl.dataset.lastDay = dl.textContent.trim();
} else {
const lastMsg = scrollEl.querySelector('.msg:last-of-type');
if (lastMsg) {
const ts = Number(lastMsg.dataset.ts || Date.now());
scrollEl.dataset.lastDay = formatDay(ts);
}
}
}
ensureLastDayFromDom();
/**
* Рендерит разделитель дня
* @param {string} dateStr - Дата в текстовом формате
*/
function renderDayDivider(dateStr) {
const div = createDayDivider(dateStr);
scrollEl.appendChild(div);
}
/**
* Рендерит сообщение в DOM
* @param {Object} msg - Объект сообщения
*/
function renderMessage(msg) {
const mine = Number(msg.author_id) === Number(meId);
// Обновляем карту аватаров
if (msg.author_id && msg.avatar && !avatarMap[msg.author_id]) {
avatarMap[msg.author_id] = msg.avatar;
}
// Проверяем день
const ts = toTimestamp(msg);
const dayStr = msg.day || formatDay(ts);
if (dayStr && scrollEl.dataset.lastDay !== dayStr) {
renderDayDivider(dayStr);
scrollEl.dataset.lastDay = dayStr;
}
// Создаём элемент сообщения
const msgElement = createMessageElement(msg, {
meId,
profileUrl,
detailUrlTemplate,
avatarMap
});
// Проверяем, нужен ли автоскролл
const keep = atBottom ? atBottom() : false;
// Добавляем в DOM
scrollEl.appendChild(msgElement);
// Автоскролл если был внизу
if (keep && autoscroll) {
autoscroll();
}
// Сообщаем другим модулям о новом сообщении
if (typeof window !== 'undefined') {
window.dispatchEvent(
new CustomEvent('chat:message-received', { detail: msg })
);
}
// Обновляем observer для последнего чужого сообщения
if (observeLastForeign) {
observeLastForeign();
}
// Отмечаем как прочитанное если был внизу
if (keep && markRead) {
markRead(ts);
}
}
/**
* Показывает индикатор "печатает..."
*/
let typingTimer = null;
function showTyping() {
if (!typingEl) return;
typingEl.classList.remove('d-none');
clearTimeout(typingTimer);
typingTimer = setTimeout(() => {
typingEl.classList.add('d-none');
}, 2000);
}
/**
* Обрабатывает успешное подключение
*/
ws.addEventListener('open', () => {
console.log('ChatWebSocket: connection established');
reconnectAttempts = 0; // Сбрасываем счетчик при успешном подключении
});
/**
* Обрабатывает входящие сообщения WebSocket
*/
ws.addEventListener('message', (e) => {
try {
const data = JSON.parse(e.data);
// Обработка ping для keepalive (игнорируем)
if (data.type === 'ping') {
return;
}
// Обработка начальной загрузки сообщений
if (data.type === 'initial_messages') {
// Убираем лоадер
const loader = scrollEl.querySelector('.initial-loader');
if (loader) {
loader.remove();
}
// Рендерим все сообщения
if (data.messages && Array.isArray(data.messages)) {
data.messages.forEach(msg => renderMessage(msg));
}
// Скроллим вниз после загрузки
if (autoscroll) {
setTimeout(() => autoscroll(), 100);
}
return;
}
// Обработка события "печатает..."
if (data.type === 'typing' && Number(data.user_id) !== Number(meId)) {
showTyping();
return;
}
// События реакций - диспетчеризуем для обработки в chat-reactions-integration.js
if (data.type === 'reaction_added') {
console.log('[ChatWebSocket] >>> Dispatching reaction_added event:', data);
window.dispatchEvent(new CustomEvent('chat:reaction-added', { detail: data }));
console.log('[ChatWebSocket] >>> Event dispatched');
return;
}
if (data.type === 'reaction_removed') {
console.log('[ChatWebSocket] >>> Dispatching reaction_removed event:', data);
window.dispatchEvent(new CustomEvent('chat:reaction-removed', { detail: data }));
console.log('[ChatWebSocket] >>> Event dispatched');
return;
}
// Событие редактирования сообщения
if (data.type === 'message_edited') {
console.log('[ChatWebSocket] >>> Dispatching message_edited event:', data);
window.dispatchEvent(new CustomEvent('chat:message-edited', { detail: data }));
console.log('[ChatWebSocket] >>> Event dispatched');
return;
}
// События обновления голосования - диспетчеризуем для обработки
if (data.type === 'poll_update') {
window.dispatchEvent(new CustomEvent('chat:poll-update', { detail: data }));
return;
}
// Обработка обычного сообщения
const msg = data.payload || data.message || data;
renderMessage(msg);
} catch (err) {
console.warn('ChatWebSocket: failed to parse message', err);
}
});
/**
* Обрабатывает закрытие соединения
*/
ws.addEventListener('close', (event) => {
console.log('ChatWebSocket: connection closed', event.code, event.reason);
// Не переподключаемся если закрытие было нормальным (код 1000)
if (event.code === 1000) {
console.log('ChatWebSocket: normal closure, not reconnecting');
return;
}
// Пробуем переподключиться
if (reconnectAttempts < maxReconnectAttempts) {
reconnectAttempts++;
console.log(`ChatWebSocket: reconnecting in ${reconnectDelay}ms (attempt ${reconnectAttempts}/${maxReconnectAttempts})`);
setTimeout(() => {
if (document.querySelector('[data-chat-ws]')) {
console.log('ChatWebSocket: attempting to reconnect...');
// Переинициализируем WebSocket с сохраненными опциями
initChatWebSocket(lastOptions);
}
}, reconnectDelay);
} else {
console.warn('ChatWebSocket: max reconnect attempts reached');
// Показываем уведомление пользователю вместо перезагрузки
const notification = document.createElement('div');
notification.className = 'alert alert-warning position-fixed bottom-0 start-50 translate-middle-x mb-3';
notification.style.zIndex = '9999';
notification.innerHTML = `
<i class="bi-exclamation-triangle me-2"></i>
Соединение с сервером потеряно.
<button class="btn btn-sm btn-link" onclick="location.reload()">Обновить страницу</button>
`;
document.body.appendChild(notification);
}
});
/**
* Обрабатывает ошибки WebSocket
*/
ws.addEventListener('error', (err) => {
console.error('ChatWebSocket: connection error', err);
// Не перезагружаем страницу при ошибке - дожидаемся события 'close'
});
/**
* Обрабатывает отправку формы
* ОТКЛЮЧЕНО: теперь отправка только через chatComposer.js -> REST API
*/
const handleFormSubmit = (e) => {
e.preventDefault();
console.log('ChatWebSocket: form submission intercepted but ignored (handled by chatComposer.js)');
// Отправка теперь обрабатывается через chatComposer.js
};
// НЕ привязываем обработчик - пусть chatComposer.js обрабатывает
// if (bindFormSubmit && form) {
// form.addEventListener('submit', handleFormSubmit);
// }
/**
* Обрабатывает ввод в textarea (индикация "печатает...")
*/
let lastTyping = 0;
ta?.addEventListener('input', () => {
const now = Date.now();
// Отправляем событие "печатает..." не чаще раз в 1.5 секунды
if (ws.readyState === WebSocket.OPEN && now - lastTyping > 1500) {
ws.send(JSON.stringify({ type: 'typing' }));
lastTyping = now;
}
});
/**
* Начальный скролл при загрузке страницы
*/
if (!window.__SCROLLED_ON_INIT__ && autoscroll) {
autoscroll();
}
// API
return {
/**
* Отправляет сообщение
* @param {string} content - Текст сообщения
*/
send: (content) => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ content }));
} else {
console.warn('ChatWebSocket: connection not open');
}
},
/**
* Отправляет индикацию "печатает..."
*/
sendTyping: () => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'typing' }));
}
},
/**
* Закрывает WebSocket соединение
*/
close: () => {
ws.close();
},
/**
* Рендерит сообщение (для внешнего использования)
*/
renderMessage,
/**
* Получает состояние соединения
* @returns {number} WebSocket.readyState
*/
getReadyState: () => ws.readyState,
/**
* Прямой доступ к WebSocket (для отладки)
*/
ws
};
}
// Публикуем в window для совместимости
if (typeof window !== 'undefined') {
window.initChatWebSocket = initChatWebSocket;
}

View File

@ -1,77 +0,0 @@
/**
* Initial Messages Loader
* Загружает начальные сообщения чата при открытии страницы
*/
export class InitialMessagesLoader {
constructor(config = {}) {
this.chatId = config.chatId;
this.fetchUrl = config.fetchUrl;
this.limit = config.limit || 30;
this.messageRenderer = config.messageRenderer;
this.onLoaded = config.onLoaded || (() => {});
this.onError = config.onError || (() => {});
}
/**
* Загружает начальные сообщения
*/
async load() {
console.log('[InitialMessagesLoader] Loading initial messages for chat:', this.chatId);
try {
const url = `${this.fetchUrl}?limit=${this.limit}`;
const response = await fetch(url, {
method: 'GET',
headers: {
'Accept': 'application/json'
}
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const data = await response.json();
if (!data.ok) {
throw new Error(data.error || 'Ошибка загрузки');
}
console.log('[InitialMessagesLoader] Loaded', data.messages.length, 'messages');
// Рендерим сообщения
if (this.messageRenderer && data.messages.length > 0) {
this.messageRenderer.renderMessages(data.messages);
}
// Убираем индикатор загрузки
const loader = document.getElementById('initialLoader');
if (loader) {
loader.remove();
}
// Callback
this.onLoaded({
messages: data.messages,
hasMore: data.has_more,
nextBeforeId: data.next_before_id,
nextBeforeTs: data.next_before_ts
});
return data;
} catch (error) {
console.error('[InitialMessagesLoader] Load failed:', error);
this.onError(error);
throw error;
}
}
}
/**
* Фабричная функция
*/
export function createInitialMessagesLoader(config) {
return new InitialMessagesLoader(config);
}

View File

@ -248,7 +248,23 @@ export function initUserWebSocket(options = {}) {
if (!state.scrollEl) return;
const { messages } = data;
if (!messages || !messages.length) return;
// Убираем индикатор загрузки независимо от наличия сообщений
const loader = document.getElementById('initialLoader');
if (loader) {
loader.remove();
}
// Отправляем событие для других модулей
window.dispatchEvent(new CustomEvent('chat:initial-messages-loaded', {
detail: { count: messages ? messages.length : 0 }
}));
// Если сообщений нет, завершаем обработку
if (!messages || !messages.length) {
console.log('[UserWS] No initial messages to render');
return;
}
// Используем MessageRenderer для добавления сообщений (с проверкой дубликатов)
if (!options.messageRenderer) {
@ -264,17 +280,6 @@ export function initUserWebSocket(options = {}) {
// Скроллим вниз
scrollToBottom(true);
// Убираем индикатор загрузки
const loader = document.getElementById('initialLoader');
if (loader) {
loader.remove();
}
// Отправляем событие для других модулей
window.dispatchEvent(new CustomEvent('chat:initial-messages-loaded', {
detail: { count: messages.length }
}));
// Инициализируем голосования после загрузки сообщений
if (window.chatPoll) {
console.log('[UserWS] Initializing polls after initial messages load');

View File

@ -3,7 +3,7 @@
* Инициализация страницы детального просмотра чата
*/
let initChatMarkRead, initChatComposer, initChatHistoryLoader, initChatFormManager, MessageRenderer, InitialMessagesLoader;
let initChatMarkRead, initChatComposer, initChatHistoryLoader, initChatFormManager, MessageRenderer;
try {
const modules = await Promise.all([
@ -11,8 +11,7 @@ try {
import('../components/chatComposer.js'),
import('../components/chatHistoryLoader.js'),
import('../components/chatFormManager.js'),
import('../components/messageRenderer.js'),
import('../components/initialMessagesLoader.js')
import('../components/messageRenderer.js')
]);
initChatMarkRead = modules[0].initChatMarkRead;
@ -20,7 +19,6 @@ try {
initChatHistoryLoader = modules[2].initChatHistoryLoader;
initChatFormManager = modules[3].initChatFormManager;
MessageRenderer = modules[4].MessageRenderer;
InitialMessagesLoader = modules[5].InitialMessagesLoader;
} catch (error) {
console.error('[ChatDetail] Failed to load modules:', error);
alert('Ошибка загрузки модулей чата: ' + error.message);
@ -200,48 +198,6 @@ function initializeComponents(config, userWs) {
// Инициализация контекстного меню перенесена в chat-detail-enhanced.js
// для избежания конфликтов и дублирования
// ========== INITIAL MESSAGES LOADER ==========
// ОТКЛЮЧЕНО: Используем WebSocket для загрузки initial_messages
// InitialMessagesLoader больше не нужен, так как WebSocket справляется с этой задачей
/*
console.log('[ChatDetail] Loading initial messages...');
const initialLoader = new InitialMessagesLoader({
chatId: config.chatId,
fetchUrl: config.messagesUrl,
limit: 30,
messageRenderer: messageRenderer,
onLoaded: (data) => {
console.log('[ChatDetail] Initial messages loaded:', data.messages.length);
// Скроллим вниз к последнему сообщению
const container = document.getElementById('chatScroll');
if (container) {
container.scrollTop = container.scrollHeight;
}
},
onError: (error) => {
console.error('[ChatDetail] Failed to load initial messages:', error);
alert('Ошибка загрузки сообщений: ' + error.message);
}
});
// Загружаем сообщения
initialLoader.load().catch(err => {
console.error('[ChatDetail] Initial load error:', err);
});
*/
console.log('[ChatDetail] Initial messages will be loaded via WebSocket');
// Убираем индикатор загрузки после подключения WebSocket
const loader = document.getElementById('initialLoader');
if (loader) {
// Скроем индикатор через WebSocket событие initial_messages
window.addEventListener('chat:initial-messages-loaded', () => {
loader.remove();
}, { once: true });
}
// ========== CONFIGURE WEBSOCKET ==========
console.log('[ChatDetail] Configuring WebSocket...');

View File

@ -0,0 +1,210 @@
# Рефакторинг загрузки сообщений в чате
## 📊 Текущая ситуация
### Модули для загрузки сообщений:
1. **`initialMessagesLoader.js`** - загрузка начальных сообщений через HTTP
- **Статус**: ❌ ЗАКОММЕНТИРОВАН в chatDetail.js
- **Проблема**: Дублирует функционал WebSocket
2. **`userWebSocket.js`** - WebSocket для всего приложения
- **Функция**: `handleInitialMessages()`
- **Проблема**: ✅ ИСПРАВЛЕНА - теперь удаляет лоадер даже для пустых чатов
3. **`chatHistoryLoader.js`** - подгрузка старых сообщений при скролле вверх
- **Статус**: ✅ РАБОТАЕТ
- **Проблема**: Нет - используется активно
4. **`messageRenderer.js`** - рендеринг сообщений в DOM
- **Статус**: ✅ РАБОТАЕТ
- **Проблема**: Дублирование логики с chatMessageTemplates.js
5. **`chatWebSocket.js`** - OLD WebSocket (устарел?)
- **Статус**: ⚠️ DEPRECATED
- **Проблема**: Дублирует userWebSocket.js
## 🎯 Проблемы архитектуры
### 1. Дублирование кода
- `initialMessagesLoader.js` полностью дублирует `handleInitialMessages()` в WebSocket
- `chatWebSocket.js` дублирует функционал `userWebSocket.js`
- Логика рендеринга размазана между несколькими модулями
### 2. Закомментированный код
- `InitialMessagesLoader` импортируется но не используется
- Создаёт путаницу и увеличивает размер бандла
### 3. Несогласованность
- Некоторые модули используют `MessageRenderer`
- Другие напрямую вызывают `createMessageElement()`
- Нет единого API для работы с сообщениями
### 4. Отсутствие единой точки управления
- Загрузка сообщений происходит в разных местах
- Сложно отследить последовательность событий
- Трудно дебажить
## ✅ План рефакторинга
### Фаза 1: Очистка мертвого кода
- [ ] Удалить `initialMessagesLoader.js` (не используется)
- [ ] Проверить использование `chatWebSocket.js`
- [ ] Если не используется - удалить
### Фаза 2: Унификация рендеринга
- [ ] Создать единый `MessagesService` или расширить `MessageRenderer`
- [ ] Все модули должны использовать один API для рендеринга
- [ ] Убрать прямые вызовы `createMessageElement()`
### Фаза 3: Централизация загрузки
Создать единый сервис `ChatMessagesLoader`:
```javascript
class ChatMessagesLoader {
constructor({ chatId, messageRenderer, websocket }) {
this.chatId = chatId;
this.messageRenderer = messageRenderer;
this.ws = websocket;
this.historyLoader = null;
}
// Начальная загрузка через WebSocket
async loadInitial() {
// Уже реализовано в userWebSocket.handleInitialMessages()
}
// Загрузка истории при скролле
async loadHistory(beforeId) {
// Использует chatHistoryLoader
}
// Загрузка конкретного сообщения
async loadMessage(messageId) {
// Новая функция
}
}
```
### Фаза 4: Упрощение chatDetail.js
- [ ] Убрать импорт неиспользуемых модулей
- [ ] Упростить инициализацию
- [ ] Сделать код более читаемым
## 📋 Детальный план действий
### Шаг 1: Аудит использования модулей
Проверить где используется:
- `initialMessagesLoader.js` - ✅ ТОЛЬКО импорт в chatDetail.js (не используется)
- `chatWebSocket.js` - ? Нужна проверка
- `MessageRenderer` - ✅ Активно используется
### Шаг 2: Безопасное удаление
1. Удалить импорт `InitialMessagesLoader` из `chatDetail.js`
2. Удалить файл `initialMessagesLoader.js`
3. Проверить что ничего не сломалось
### Шаг 3: Проверка chatWebSocket.js
Найти все места использования:
```bash
grep -r "chatWebSocket" backend/static/js/**/*.js
grep -r "initChatWebSocket" backend/templates/**/*.html
```
Если не используется - удалить.
### Шаг 4: Унификация MessageRenderer
Текущие вызовы:
- `messageRenderer.renderMessage(msg)` - ✅ Хорошо
- `messageRenderer.renderMessages(messages)` - ✅ Хорошо
- `createMessageElement(msg, options)` - ❌ Плохо (прямой вызов)
Решение:
```javascript
// Все должны использовать MessageRenderer
messageRenderer.renderMessage(msg);
// Вместо прямого createMessageElement()
```
### Шаг 5: Документация
После рефакторинга обновить:
- `CHAT_PAGE_RENDERING_ARCHITECTURE.md`
- Добавить диаграммы потока данных
- Описать API всех модулей
## 🎨 Итоговая архитектура
```
┌─────────────────────────────────────────┐
│ Chat Detail Page │
└─────────────────────────────────────────┘
│
▼
┌─────────────────────────────┐
│ chatDetail.js (main) │
└─────────────────────────────┘
│
┌───────────┼───────────┐
▼ ▼ ▼
┌──────────┐ ┌────────┐ ┌────────────┐
│ userWS │ │Renderer│ │HistoryLoad │
│(initial) │ │ │ │(scroll up) │
└──────────┘ └────────┘ └────────────┘
│ │ │
└───────────┴───────────┘
│
▼
┌──────────────────┐
│ MessageRenderer │
│ (single API) │
└──────────────────┘
│
▼
┌──────────────────┐
│ DOM (chat) │
└──────────────────┘
```
## ⚠️ Риски
1. **Разрыв обратной совместимости**
- Решение: Тщательное тестирование после каждого шага
2. **Потеря функциональности**
- Решение: Написать тесты до рефакторинга
3. **Регрессия в других частях приложения**
- Решение: Проверить все страницы с чатами
## 🧪 Тестирование
После каждого шага проверять:
- [ ] Начальная загрузка сообщений работает
- [ ] Загрузка истории при скролле работает
- [ ] Новые сообщения появляются в реальном времени
- [ ] Пустые чаты отображаются корректно
- [ ] Лоадер корректно скрывается
- [ ] Индикатор "печатает" работает
- [ ] Отметка "прочитано" работает
## 📝 Статус выполнения
- [x] Исправлен баг с лоадером в пустых чатах (`userWebSocket.js`)
- [ ] Удалить `initialMessagesLoader.js`
- [ ] Проверить `chatWebSocket.js`
- [ ] Унифицировать API рендеринга
- [ ] Обновить документацию
- [ ] Написать тесты
## 🔄 Следующие шаги
1. Получить одобрение плана
2. Начать с Фазы 1 (удаление мертвого кода)
3. Тестировать после каждого изменения
4. Закомитить изменения маленькими порциями