diff --git a/backend/static/js/components/chatWebSocket.js b/backend/static/js/components/chatWebSocket.js deleted file mode 100644 index 8b989ff1..00000000 --- a/backend/static/js/components/chatWebSocket.js +++ /dev/null @@ -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 = ` - - Соединение с сервером потеряно. - - `; - 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; -} diff --git a/backend/static/js/components/initialMessagesLoader.js b/backend/static/js/components/initialMessagesLoader.js deleted file mode 100644 index 5cb7433c..00000000 --- a/backend/static/js/components/initialMessagesLoader.js +++ /dev/null @@ -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); -} diff --git a/backend/static/js/components/userWebSocket.js b/backend/static/js/components/userWebSocket.js index 97a120da..b6c3fa19 100644 --- a/backend/static/js/components/userWebSocket.js +++ b/backend/static/js/components/userWebSocket.js @@ -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'); diff --git a/backend/static/js/pages/chatDetail.js b/backend/static/js/pages/chatDetail.js index d9f44262..fe035785 100644 --- a/backend/static/js/pages/chatDetail.js +++ b/backend/static/js/pages/chatDetail.js @@ -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...'); diff --git a/docs/in-progress/MESSAGES_LOADING_REFACTORING.md b/docs/in-progress/MESSAGES_LOADING_REFACTORING.md new file mode 100644 index 00000000..bba1c0bc --- /dev/null +++ b/docs/in-progress/MESSAGES_LOADING_REFACTORING.md @@ -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. Закомитить изменения маленькими порциями