Corp/backend/static/js/components/userWebSocket.js
Игорь Надеин 334d58c2f5 fix(messenger): полное устранение конфликтов прокрутки
ГЛУБОКИЙ АУДИТ выявил корневые причины прыжков:

НАЙДЕННЫЕ ПРОБЛЕМЫ:
1. chatMarkRead делал свою прокрутку ПОСЛЕ handleInitialMessages
   - mark.scrollIntoView() или autoscroll() в requestAnimationFrame
   - Конфликтовал с уже установленной прокруткой

2. Дублирование обработчика кнопки 'вниз':
   - chatMarkRead.js: btn.addEventListener
   - chat-detail-enhanced.js: scrollBtn.addEventListener
   - Два обработчика на одну кнопку!

3. Излишняя сложность в scrollToBottom():
   - Вызывал markReadApi.autoscroll() вместо прямой установки
   - Дополнительный уровень косвенности = источник багов

ИСПРАВЛЕНИЯ:
1. chatMarkRead НЕ скроллит при инициализации:
   - Divider создается, но не скроллится к нему
   - Прокрутка управляется ТОЛЬКО из handleInitialMessages
   - Единая точка ответственности

2. Убрано дублирование кнопки:
   - Удален обработчик из chat-detail-enhanced.js
   - Остался только в chatMarkRead.js

3. Упрощен scrollToBottom():
   - Прямая установка scrollTop вместо вызова markReadApi
   - Поддержка instant режима (без анимации)
   - Предсказуемое поведение

4. Добавлены комментарии и документация:
   - Пояснено почему НЕ скроллим в chatMarkRead
   - Описано назначение каждой функции прокрутки
   - Создан полный аудит в docs/completed/SCROLL_CONFLICTS_AUDIT.md

АРХИТЕКТУРА ПРОКРУТКИ:
✅ При загрузке: ТОЛЬКО handleInitialMessages
✅ Кнопка вниз: chatMarkRead.autoscroll()
✅ Новые сообщения: ТОЛЬКО если свое ИЛИ уже внизу
✅ Ввод текста: autoscroll() если внизу

РЕЗУЛЬТАТ:
✅ Нет прыжков - единая точка управления прокруткой
✅ Нет конфликтов - каждая функция делает одно
✅ Нет дублирования - один обработчик на событие
✅ Предсказуемость - прямые вызовы вместо косвенных

Файлы:
- userWebSocket.js: упрощен scrollToBottom
- chatMarkRead.js: убрана автопрокрутка при инициализации
- chat-detail-enhanced.js: убран дублирующий обработчик
- docs/completed/SCROLL_CONFLICTS_AUDIT.md: полный аудит
2026-01-12 11:11:02 +03:00

657 lines
20 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);
// ОПТИМИЗАЦИЯ: Скрываем контейнер перед рендерингом для предотвращения reflow
const wasVisible = state.scrollEl.style.visibility;
state.scrollEl.style.visibility = 'hidden';
// Используем батчевый renderMessages вместо forEach для лучшей производительности
options.messageRenderer.renderMessages(messages);
// Используем requestAnimationFrame для гарантии что DOM обновлен перед прокруткой
requestAnimationFrame(() => {
// Устанавливаем scroll в конец ДО показа контейнера
state.scrollEl.scrollTop = state.scrollEl.scrollHeight;
// Показываем контейнер - пользователь сразу видит конец диалога
state.scrollEl.style.visibility = wasVisible || '';
});
// Инициализируем голосования после загрузки сообщений
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?.() ?? false;
const isOwnMessage = message.author_id === userId;
if (isOwnMessage || isAtBottom) {
// Используем requestAnimationFrame для плавности
requestAnimationFrame(() => {
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;
// Прямая установка scrollTop для предсказуемости
// Избегаем вызова markReadApi.autoscroll чтобы не было конфликтов
if (instant) {
const prev = state.scrollEl.style.scrollBehavior;
state.scrollEl.style.scrollBehavior = 'auto';
state.scrollEl.scrollTop = state.scrollEl.scrollHeight;
if (prev) {
state.scrollEl.style.scrollBehavior = prev;
} else {
state.scrollEl.style.removeProperty('scroll-behavior');
}
} 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;
}