Corp/backend/static/js/components/chatWebSocket.js

286 lines
8.5 KiB
JavaScript

/**
* @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 если параметры невалидны
*/
export function initChatWebSocket(options = {}) {
const {
chatId,
meId,
scrollContainerId = 'chatScroll',
formId = 'chatForm',
textareaId = 'id_content',
typingIndicatorId = 'typing',
profileUrl = '/employees/profile/',
detailUrlTemplate = '/employees/0/',
avatarMap = {},
markReadApi
} = options;
// Проверка обязательных параметров
if (!chatId || !meId) {
console.warn('ChatWebSocket: chatId and meId are required');
return null;
}
// Получение элементов
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);
/**
* Инициализирует последний день из 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();
}
// Обновляем 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);
}
/**
* Обрабатывает входящие сообщения WebSocket
*/
ws.addEventListener('message', (e) => {
try {
const data = JSON.parse(e.data);
// Обработка события "печатает..."
if (data.type === 'typing' && Number(data.user_id) !== Number(meId)) {
showTyping();
return;
}
// Обработка обычного сообщения
const msg = data.payload || data.message || data;
renderMessage(msg);
} catch (err) {
console.warn('ChatWebSocket: failed to parse message', err);
}
});
/**
* Обрабатывает закрытие соединения
*/
ws.addEventListener('close', () => {
console.log('ChatWebSocket: connection closed, reloading in 2s...');
setTimeout(() => location.reload(), 2000);
});
/**
* Обрабатывает ошибки WebSocket
*/
ws.addEventListener('error', (err) => {
console.error('ChatWebSocket: connection error', err);
});
/**
* Обрабатывает отправку формы
*/
form?.addEventListener('submit', (e) => {
e.preventDefault();
const text = (ta?.value || '').trim();
if (!text || ws.readyState !== WebSocket.OPEN) return;
// Отправка сообщения
ws.send(JSON.stringify({ content: text }));
// Очистка textarea
ta.value = '';
ta.dispatchEvent(new Event('input'));
});
/**
* Обрабатывает ввод в 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;
}