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

Загрузка initial messages теперь происходит только через:
- userWebSocket.handleInitialMessages() - начальные сообщения
- chatHistoryLoader - подгрузка истории при скролле
2026-01-12 10:27:38 +03:00

632 lines
19 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* @fileoverview User WebSocket - единое WebSocket соединение для пользователя
* Обрабатывает все real-time обновления:
* - Чаты и сообщения
* - Уведомления
* - Бейджи в sidebar
* - Онлайн-статус
* - Календарь и другие события
* @module components/userWebSocket
*/
import { getChatAvatar } from './chatAvatarMap.js';
import { formatDay, createDayDivider, createMessageElement } from './chatMessageTemplates.js';
/**
* Инициализирует WebSocket соединение для пользователя
* @param {Object} options - Опции инициализации
* @param {number} options.userId - 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 {Object} [options.avatarMap] - Карта аватаров пользователей
* @param {Object} [options.markReadApi] - API из chatMarkRead для интеграции
* @param {Object} [options.badgeManager] - Менеджер бейджей для обновления счетчиков
* @param {Function} [options.onListUpdate] - Callback для обновления списка чатов
* @param {Function} [options.onNotification] - Callback для уведомлений (будущее)
* @returns {Object} API WebSocket
*/
let activeConnection = null;
export function initUserWebSocket(options = {}) {
const {
userId,
scrollContainerId = 'chatScroll',
formId = 'chatForm',
textareaId = 'id_content',
typingIndicatorId = 'typing',
avatarMap = {},
markReadApi,
badgeManager,
onListUpdate
} = options;
if (!userId) {
console.warn('[UserWS] userId is required');
return null;
}
// Закрываем существующее соединение
if (activeConnection) {
console.log('[UserWS] Closing existing connection');
activeConnection.close(1000, 'Reconnecting');
}
// Состояние
const state = {
activeChatId: null,
scrollEl: null,
form: null,
textarea: null,
typingEl: null,
reconnectAttempts: 0,
maxReconnectAttempts: 5,
reconnectDelay: 3000,
typingTimeout: null,
lastTypingSent: 0,
typingThrottle: 3000
};
// Создание WebSocket соединения
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
const wsUrl = `${proto}://${location.host}/ws/`;
const ws = new WebSocket(wsUrl);
activeConnection = ws;
console.log('[UserWS] Connecting to', wsUrl);
// ==================== WebSocket события ====================
ws.onopen = () => {
console.log('[UserWS] Connected');
state.reconnectAttempts = 0;
// Если есть активный чат на странице, открываем его
const currentChatId = detectCurrentChatId();
if (currentChatId) {
openChat(currentChatId, true);
}
};
ws.onclose = (event) => {
console.log('[UserWS] Disconnected', event.code, event.reason);
if (event.code !== 1000 && state.reconnectAttempts < state.maxReconnectAttempts) {
state.reconnectAttempts++;
console.log(`[UserWS] Reconnecting attempt ${state.reconnectAttempts}...`);
setTimeout(() => {
initUserWebSocket(options);
}, state.reconnectDelay);
}
};
ws.onerror = (error) => {
console.error('[UserWS] Error:', error);
};
ws.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
handleMessage(data);
} catch (err) {
console.error('[UserWS] Failed to parse message:', err);
}
};
// ==================== Обработка входящих сообщений ====================
function handleMessage(data) {
const { type } = data;
console.log('[UserWebSocket] Received message:', type, data);
switch (type) {
case 'ping':
// Keepalive - просто логируем
break;
case 'chat_opened':
console.log('[UserWS] Chat opened:', data.chat_id);
break;
case 'chat_closed':
console.log('[UserWS] Chat closed:', data.chat_id);
break;
case 'initial_messages':
handleInitialMessages(data);
break;
case 'new_message':
handleNewMessage(data);
break;
case 'message_updated':
handleMessageUpdated(data);
break;
case 'message_deleted':
console.log('[UserWebSocket] Handling message_deleted event');
handleMessageDeleted(data);
break;
case 'list_update':
handleListUpdate(data);
break;
case 'message_edited':
handleMessageEditedInList(data);
break;
case 'reaction_added':
handleReactionAdded(data);
break;
case 'reaction_removed':
handleReactionRemoved(data);
break;
case 'typing_start':
handleTypingStart(data);
break;
case 'typing_stop':
handleTypingStop(data);
break;
case 'poll_vote':
case 'poll_update':
handlePollUpdate(data);
break;
case 'error':
console.error('[UserWS] Server error:', data.error);
break;
default:
console.warn('[UserWS] Unknown message type:', type);
}
}
// ==================== Управление активным чатом ====================
function detectCurrentChatId() {
// Определяем ID чата из DOM (если мы на странице чата)
const scrollEl = document.getElementById(scrollContainerId);
if (scrollEl) {
const chatId = scrollEl.dataset.chatId;
if (chatId) {
return parseInt(chatId);
}
}
return null;
}
function openChat(chatId, loadHistory = false) {
console.log('[UserWS] Opening chat:', chatId);
state.activeChatId = chatId;
// Получаем элементы
state.scrollEl = document.getElementById(scrollContainerId);
state.form = document.getElementById(formId);
state.textarea = document.getElementById(textareaId);
state.typingEl = document.getElementById(typingIndicatorId);
// Отправляем на сервер
send({
action: 'open_chat',
chat_id: chatId,
load_history: loadHistory
});
// ОТКЛЮЧЕНО: Форма теперь обрабатывается chatComposer.js
// initChatFormHandlers() больше не используется
}
function closeChat(chatId) {
console.log('[UserWS] Closing chat:', chatId);
send({
action: 'close_chat',
chat_id: chatId
});
state.activeChatId = null;
state.scrollEl = null;
state.form = null;
state.textarea = null;
state.typingEl = null;
}
// ==================== Обработчики событий чата ====================
function handleInitialMessages(data) {
if (!state.scrollEl) return;
const { messages } = data;
// Убираем индикатор загрузки независимо от наличия сообщений
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) {
console.error('[UserWebSocket] MessageRenderer not configured for initial_messages!');
return;
}
console.log('[UserWS] Rendering %d initial messages via MessageRenderer', messages.length);
messages.forEach(msg => {
options.messageRenderer.renderMessage(msg);
});
// Скроллим вниз
scrollToBottom(true);
// Инициализируем голосования после загрузки сообщений
if (window.chatPoll) {
console.log('[UserWS] Initializing polls after initial messages load');
window.chatPoll.initializeExistingPolls();
}
}
function handleNewMessage(data) {
if (!state.scrollEl) return;
const { message } = data;
if (!message) return;
// Рендерим сообщение через MessageRenderer
if (!options.messageRenderer) {
console.error('[UserWebSocket] MessageRenderer not configured!');
return;
}
options.messageRenderer.renderMessage(message);
// Автоскролл если внизу
const isAtBottom = markReadApi?.atBottom?.() ?? true;
if (isAtBottom || message.author_id === userId) {
scrollToBottom();
}
// Отмечаем как прочитанное
const msgEl = state.scrollEl.querySelector(`[data-message-id="${message.id}"]`);
if (msgEl && markReadApi?.observeLastForeign) {
markReadApi.observeLastForeign(msgEl);
}
// Инициализируем реакции для нового сообщения
window.dispatchEvent(new CustomEvent('chat:message-added', {
detail: { messageElement: msgEl, messageId: message.id }
}));
// Если в сообщении есть голосование, инициализируем его
if (message.poll && window.chatPoll) {
console.log('[UserWS] New message with poll detected, poll_id=%s', message.poll.id);
// Небольшая задержка чтобы DOM успел обновиться
setTimeout(() => {
window.chatPoll.refreshPoll(message.poll.id);
}, 100);
}
}
function handleMessageUpdated(data) {
const { message } = data;
if (!message) return;
// УСТАРЕЛО: Старая логика частичного обновления
// Теперь используем полную перерисовку через событие
console.log('[UserWebSocket] message_updated received, dispatching chat:message-edited');
// Диспатчим событие для полной перерисовки
window.dispatchEvent(new CustomEvent('chat:message-edited', {
detail: {
payload: message,
chat_id: data.chat_id
}
}));
}
function handleMessageDeleted(data) {
console.log('[UserWebSocket] handleMessageDeleted called:', data);
const { message_id } = data;
if (!message_id) {
console.warn('[UserWebSocket] No message_id in delete event');
return;
}
console.log('[UserWebSocket] Looking for message element with id:', message_id);
console.log('[UserWebSocket] Scroll element:', state.scrollEl?.id);
const msgEl = state.scrollEl?.querySelector(`[data-message-id="${message_id}"]`);
if (msgEl) {
console.log('[UserWebSocket] Message element found:', msgEl);
console.log('[UserWebSocket] Message element classes:', msgEl.className);
console.log('[UserWebSocket] Removing message from DOM...');
// Плавная анимация удаления
msgEl.style.transition = 'opacity 0.3s ease-out, transform 0.3s ease-out';
msgEl.style.opacity = '0';
msgEl.style.transform = 'scale(0.8)';
setTimeout(() => {
msgEl.remove();
console.log('[UserWebSocket] Message removed successfully');
}, 300);
} else {
console.warn('[UserWebSocket] Message element NOT found for id:', message_id);
console.log('[UserWebSocket] Available messages:',
Array.from(state.scrollEl?.querySelectorAll('[data-message-id]') || [])
.map(el => el.dataset.messageId)
);
}
}
function handleListUpdate(data) {
const { chat_id, message } = data;
if (!chat_id || !message) return;
// Обновляем бейдж (если сообщение не от нас)
if (badgeManager && message.author_id !== userId) {
badgeManager.incrementChat(chat_id);
}
// Обновляем карточку чата в списке
if (onListUpdate) {
onListUpdate(chat_id, message);
}
// Диспатчим событие для других модулей
window.dispatchEvent(new CustomEvent('chat:list-update', {
detail: { chatId: chat_id, message }
}));
}
function handleMessageEditedInList(data) {
// Обновление последнего сообщения в списке чатов
if (onListUpdate) {
onListUpdate(data.chat_id, data.message);
}
// Диспатчим событие для обновления сообщения в открытом чате
window.dispatchEvent(new CustomEvent('chat:message-edited', {
detail: {
payload: data.message, // Данные сообщения
chat_id: data.chat_id
}
}));
}
function handleReactionAdded(data) {
const { message_id, emoji, user_id, user_name, reactions_summary } = data;
window.dispatchEvent(new CustomEvent('chat:reaction-added', {
detail: {
messageId: message_id,
emoji,
userId: user_id,
userName: user_name,
reactions_summary: reactions_summary // ← ДОБАВЛЕНО
}
}));
}
function handleReactionRemoved(data) {
const { message_id, emoji, user_id, reactions_summary } = data;
window.dispatchEvent(new CustomEvent('chat:reaction-removed', {
detail: {
messageId: message_id,
emoji,
userId: user_id,
reactions_summary: reactions_summary
}
}));
}
function handleTypingStart(data) {
const { user_name } = data;
if (state.typingEl) {
state.typingEl.textContent = `${user_name} печатает...`;
state.typingEl.classList.remove('d-none');
}
}
function handleTypingStop(data) {
if (state.typingEl) {
state.typingEl.classList.add('d-none');
}
}
function handlePollUpdate(data) {
console.log('[UserWebSocket] Poll update received:', data);
window.dispatchEvent(new CustomEvent('chat:poll-update', {
detail: {
poll_id: data.poll_id,
message_id: data.message_id,
results: data.results
}
}));
}
// ==================== Отправка сообщений ====================
// УДАЛЕНО: initChatFormHandlers() больше не используется
// Форма теперь обрабатывается chatComposer.js через стандартный HTML submit
// Отправка сообщений идёт через HTTP POST, а не через WebSocket
function sendMessage(content) {
send({
action: 'send_message',
content: content
});
}
function sendTyping() {
send({
action: 'typing'
});
}
function sendStopTyping() {
send({
action: 'stop_typing'
});
}
// ==================== Вспомогательные методы ====================
function send(data) {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify(data));
} else {
console.warn('[UserWS] Cannot send, connection not open');
}
}
function scrollToBottom(instant = false) {
if (!state.scrollEl) return;
if (markReadApi?.autoscroll) {
markReadApi.autoscroll(instant);
} else {
state.scrollEl.scrollTop = state.scrollEl.scrollHeight;
}
}
// ==================== Public API ====================
const api = {
ws,
/**
* Динамическая конфигурация WebSocket
* Позволяет расширять функциональность после инициализации
* @param {Object} newOptions - Дополнительные опции
*/
configure: (newOptions = {}) => {
console.log('[UserWS] Configuring with:', Object.keys(newOptions));
// Обновляем DOM элементы
if (newOptions.scrollContainerId) {
state.scrollEl = document.getElementById(newOptions.scrollContainerId);
}
if (newOptions.formId) {
state.form = document.getElementById(newOptions.formId);
}
if (newOptions.textareaId) {
state.textarea = document.getElementById(newOptions.textareaId);
}
if (newOptions.typingIndicatorId) {
state.typingEl = document.getElementById(newOptions.typingIndicatorId);
}
// Обновляем avatarMap
if (newOptions.avatarMap) {
Object.assign(avatarMap, newOptions.avatarMap);
}
// Обновляем markReadApi
if (newOptions.markReadApi) {
options.markReadApi = newOptions.markReadApi;
}
// Обновляем messageRenderer
if (newOptions.messageRenderer) {
options.messageRenderer = newOptions.messageRenderer;
}
// Обновляем callbacks
if (newOptions.onListUpdate) {
options.onListUpdate = newOptions.onListUpdate;
}
console.log('[UserWS] Configuration updated');
},
// Управление чатом
openChat,
closeChat,
// Отправка сообщений
sendMessage,
// Реакции
addReaction: (messageId, emoji) => {
send({
action: 'add_reaction',
message_id: messageId,
emoji: emoji
});
},
removeReaction: (messageId, emoji) => {
send({
action: 'remove_reaction',
message_id: messageId,
emoji: emoji
});
},
// Редактирование
editMessage: (messageId, content) => {
send({
action: 'edit_message',
message_id: messageId,
content: content
});
},
// Удаление
deleteMessage: (messageId) => {
send({
action: 'delete_message',
message_id: messageId
});
},
// Отметка прочитанного
markRead: (chatId) => {
send({
action: 'mark_read',
chat_id: chatId
});
},
// Голосование
votePoll: (pollId, optionIds) => {
send({
action: 'vote_poll',
poll_id: pollId,
option_ids: optionIds
});
},
// Состояние
getActiveChatId: () => state.activeChatId,
isConnected: () => ws.readyState === WebSocket.OPEN
};
return api;
}