286 lines
8.5 KiB
JavaScript
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;
|
|
}
|