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