/** * @fileoverview ChatController V2 - рефакторированный координатор чата * @module controllers/chatControllerV2 * * РЕФАКТОРИНГ: * - Чистая архитектура с разделением ответственности * - Минимум логирования (только важные события) * - Улучшенная обработка ошибок * - Полная типизация JSDoc * - Поддержка отмены операций */ import { MessageStoreV2 } from '../stores/messageStoreV2.js'; import { MessageLoaderV2 } from '../loaders/messageLoaderV2.js'; import { MessageRendererV2 } from '../renderers/messageRendererV2.js'; import { ScrollManagerV2 } from '../managers/scrollManagerV2.js'; import { DateGroupManager } from '../managers/dateGroupManager.js'; import { StickyDateObserver } from '../observers/stickyDateObserver.js'; import { CHAT_EVENTS, WS_EVENTS, SCROLL_CONFIG, AUTOSCROLL_CONFIG } from '../config/chatConfig.js'; /** * @typedef {Object} ChatControllerOptions * @property {number} chatId - ID чата * @property {HTMLElement} scrollElement - Контейнер для скролла * @property {number} currentUserId - ID текущего пользователя * @property {string} [containerId='chatScroll'] - ID контейнера сообщений * @property {Object} [wsConnection] - WebSocket соединение * @property {string} [profileUrl] - URL профиля пользователя * @property {string} [detailUrlTemplate] - Шаблон URL детальной информации * @property {number|null} [lastReadMessageId] - ID последнего прочитанного сообщения */ /** * ChatController V2 - главный координатор чата * Единая точка входа для работы с чатом. */ export class ChatControllerV2 { /** * @param {ChatControllerOptions} options */ constructor(options) { this._validateOptions(options); // Основные параметры this.chatId = options.chatId; this.currentUserId = options.currentUserId; this.scrollElement = options.scrollElement; this.containerId = options.containerId || 'chatScroll'; this.lastReadMessageId = options.lastReadMessageId || null; this.lastReadTimestamp = options.lastReadTimestamp || null; // Создаем компоненты this.store = new MessageStoreV2({ currentUserId: this.currentUserId }); // Date Group Manager для группировки по датам this.dateGroupManager = new DateGroupManager({ container: this.scrollElement, enableSticky: true, interactive: true // Кликабельные даты }); // Sticky Date Observer для IntersectionObserver this.stickyDateObserver = new StickyDateObserver({ container: this.scrollElement, onSticky: (isSticky, element, dateText) => { console.log('[ChatControllerV2] Date sticky state:', { dateText, isSticky }); } }); this.loader = new MessageLoaderV2({ store: this.store, wsConnection: options.wsConnection, currentUserId: this.currentUserId }); this.renderer = new MessageRendererV2({ store: this.store, containerId: this.containerId, currentUserId: this.currentUserId, dateGroupManager: this.dateGroupManager, // Передаем в renderer profileUrl: options.profileUrl || '/employees/profile/', detailUrlTemplate: options.detailUrlTemplate || '/employees/detail/0/' }); this.scrollManager = new ScrollManagerV2({ scrollElement: this.scrollElement, loader: this.loader, renderer: this.renderer, store: this.store, chatId: this.chatId }); // Состояние this._initialized = false; this._initializing = false; this._destroyed = false; // Индикатор новых сообщений (умный автоскролл) this._newMessagesCount = 0; this._newMessagesBtn = null; this._scrollWatcherCleanup = null; // Слушатели для cleanup this._eventListeners = []; // Подписки this._setupStoreSubscription(); if (options.wsConnection) { this._setupWebSocketListeners(); } console.log('[ChatControllerV2] Created for chat:', this.chatId); } // ==================== Публичное API - Жизненный цикл ==================== /** * Инициализирует чат: загружает сообщения, рендерит, настраивает скролл * @returns {Promise} */ async init() { if (this._initialized) { console.warn('[ChatControllerV2] Already initialized'); return; } if (this._initializing) { console.warn('[ChatControllerV2] Initialization in progress'); return; } this._initializing = true; try { // 1. Загружаем сообщения // Используем timestamp как fallback если нет message ID (API поддерживает оба) const anchorValue = this.lastReadMessageId || this.lastReadTimestamp; const loadResult = await this.loader.loadInitial(this.chatId, { aroundMessageId: anchorValue }); // 2. Скрываем контейнер для плавного рендера this.scrollElement.style.visibility = 'hidden'; try { // 3. Рендерим сообщения await this.renderer.render(this.chatId); // 4. СНАЧАЛА показываем контейнер (важно для scrollTop!) this.scrollElement.style.visibility = ''; // Ждем один frame чтобы браузер применил visibility await new Promise(resolve => requestAnimationFrame(resolve)); // 5. ✨ НОВЫЙ ПОДХОД: Мгновенное позиционирование БЕЗ скролла if (loadResult.anchorId) { // Есть lastRead - СРАЗУ позиционируем на него await this._positionToMessage(loadResult.anchorId); } else { // Нет anchor - позиционируем в самый низ (новый чат) await new Promise(resolve => { requestAnimationFrame(() => { this.scrollElement.scrollTop = this.scrollElement.scrollHeight; console.log('[ChatControllerV2] Initial position: bottom (no anchor)'); resolve(); }); }); } // 6. Инициализируем ScrollManager await this.scrollManager.init(); // 7. Инициализируем StickyDateObserver this.stickyDateObserver.init(); // 8. Настраиваем обработчик кликов на даты this._setupDateClickHandler(); // 9. Инициализируем watcher для умного автоскролла this._initScrollWatcher(); } catch (error) { this.scrollElement.style.visibility = ''; throw error; } this._initialized = true; this._initializing = false; // Dispatch события this._emit(CHAT_EVENTS.INITIALIZED, { chatId: this.chatId }); console.log('[ChatControllerV2] Initialized successfully'); } catch (error) { this._initializing = false; console.error('[ChatControllerV2] Initialization failed:', error); this._emit(CHAT_EVENTS.ERROR, { error, phase: 'init' }); throw error; } } /** * Уничтожает контроллер и освобождает ресурсы */ destroy() { if (this._destroyed) return; this._destroyed = true; // Отменяем активные запросы this.loader.cancelAll(this.chatId); // Уничтожаем ScrollManager this.scrollManager?.destroy(); // Уничтожаем StickyDateObserver this.stickyDateObserver?.destroy(); // Очищаем DateGroupManager this.dateGroupManager?.clear(); // Очищаем Store this.store?.clearChat(this.chatId); // Очищаем Renderer this.renderer?.clear(); // Удаляем scroll watcher if (this._scrollWatcherCleanup) { this._scrollWatcherCleanup(); this._scrollWatcherCleanup = null; } // Удаляем слушатели событий this._eventListeners.forEach(({ event, handler }) => { window.removeEventListener(event, handler); }); this._eventListeners = []; console.log('[ChatControllerV2] Destroyed'); } // ==================== Публичное API - Сообщения ==================== /** * Отправляет сообщение * @param {string} content - Текст сообщения * @param {Object} [options] - Дополнительные опции * @returns {string|null} Временный ID или null при ошибке */ sendMessage(content, options = {}) { if (!content?.trim()) { console.warn('[ChatControllerV2] Cannot send empty message'); return null; } const tempId = this.loader.sendOptimistic(this.chatId, content.trim(), options); return tempId; } /** * Получает сообщение по ID * @param {number|string} messageId * @returns {Object|null} */ getMessage(messageId) { return this.store.getMessage(messageId); } /** * Получает все сообщения чата * @param {Object} [options] * @returns {Array} */ getMessages(options = {}) { return this.store.getMessagesForChat(this.chatId, options); } // ==================== Публичное API - Скролл ==================== /** * Загружает больше истории * @returns {Promise} */ async loadMoreHistory() { return this.scrollManager.loadMoreHistory(); } /** * Прокручивает к последнему сообщению * @param {Object} [options] */ scrollToBottom(options = {}) { this.scrollManager.scrollToBottom(options); } /** * Прокручивает к конкретному сообщению * @param {number|string} messageId * @param {Object} [options] */ scrollToMessage(messageId, options = {}) { this.scrollManager.scrollToMessage(messageId, options); } /** * Проверяет находится ли пользователь внизу чата * @returns {boolean} */ isNearBottom() { return this.scrollManager.isNearBottom(); } // ==================== Публичное API - Состояние ==================== /** * Получает статус контроллера * @returns {Object} */ getStatus() { return { chatId: this.chatId, initialized: this._initialized, messageCount: this.store.getMessageCount(this.chatId), isLoading: this.loader.isLoading(this.chatId), hasMoreHistory: this.loader.hasMoreHistory(this.chatId), scroll: this.scrollManager.getStatus(), store: this.store.getStats(), renderer: this.renderer.getStats() }; } /** * Проверяет инициализирован ли контроллер * @returns {boolean} */ get isInitialized() { return this._initialized; } // ==================== Приватные методы - Подписки ==================== /** * Настраивает подписку на Store * @private */ _setupStoreSubscription() { this.store.subscribe((event, data) => { // Игнорируем события во время инициализации if (this._initializing) return; this._handleStoreEvent(event, data); }); } /** * Обрабатывает события Store * @private */ _handleStoreEvent(event, data) { switch (event) { case 'message_added': this._onMessageAdded(data); break; case 'message_updated': this._onMessageUpdated(data); break; case 'message_removed': this._onMessageRemoved(data); break; } } /** * Обработчик добавления сообщения * @private */ _onMessageAdded(data) { const { message, optimistic } = data; if (message.chat_id !== this.chatId) return; // Рендерим новое сообщение this.renderer.appendMessage(message, this.chatId); // Обновляем StickyDateObserver (могла появиться новая date group) if (this.stickyDateObserver?.isInitialized()) { this.stickyDateObserver.refresh(); } // УМНЫЙ АВТОСКРОЛЛ: // 1. Своё сообщение - всегда скроллим // 2. Чужое сообщение + внизу чата - скроллим (активно читаем) // 3. Чужое сообщение + читаем историю - показываем индикатор const isMyMessage = message.author_id === this.currentUserId; const isAtBottom = this.scrollManager.isNearBottom(); const shouldScroll = isMyMessage || isAtBottom; if (shouldScroll) { // Для своих сообщений - мгновенный скролл (AUTOSCROLL_CONFIG.SMOOTH_SCROLL_FOR_OWN) // Для чужих - проверка уже прошла выше (isAtBottom) this.scrollManager.scrollToBottom({ instant: isMyMessage && !AUTOSCROLL_CONFIG.SMOOTH_SCROLL_FOR_OWN, force: isMyMessage }); // Скрываем индикатор если он показан this._hideNewMessagesIndicator(); } else if (!shouldScroll && !optimistic) { // Показываем индикатор только для НЕ-оптимистичных чужих сообщений this._showNewMessagesIndicator(); } this._emit(CHAT_EVENTS.MESSAGE_ADDED, { messageId: message.id, chatId: this.chatId }); } /** * Обработчик обновления сообщения * @private */ _onMessageUpdated(data) { const { messageId, updates } = data; this.renderer.updateMessage(messageId, updates); this._emit(CHAT_EVENTS.MESSAGE_UPDATED, { messageId, updates, chatId: this.chatId }); } /** * Обработчик удаления сообщения * @private */ _onMessageRemoved(data) { const { messageId } = data; this.renderer.removeMessage(messageId); this._emit(CHAT_EVENTS.MESSAGE_REMOVED, { messageId, chatId: this.chatId }); } /** * Настраивает слушатели WebSocket * @private */ _setupWebSocketListeners() { const handlers = [ [WS_EVENTS.NEW_MESSAGE, (e) => this._handleWsNewMessage(e.detail)], [WS_EVENTS.MESSAGE_EDITED, (e) => this._handleWsMessageEdited(e.detail)], [WS_EVENTS.MESSAGE_REMOVED, (e) => this._handleWsMessageRemoved(e.detail)], [WS_EVENTS.REACTION_ADDED, (e) => this._handleWsReaction(e.detail)], [WS_EVENTS.REACTION_REMOVED, (e) => this._handleWsReaction(e.detail)] ]; handlers.forEach(([event, handler]) => { window.addEventListener(event, handler); this._eventListeners.push({ event, handler }); }); } /** * @private */ _handleWsNewMessage(detail) { const { message, chatId } = detail; if (chatId === this.chatId && message) { this.loader.handleNewMessage(message); } } /** * @private */ _handleWsMessageEdited(detail) { const { message, chatId } = detail; if (chatId === this.chatId && message) { this.loader.handleMessageEdited({ message }); } } /** * @private */ _handleWsMessageRemoved(detail) { const { messageId, chatId } = detail; if (chatId === this.chatId && messageId) { this.loader.handleMessageRemoved({ message_id: messageId }); } } /** * @private */ _handleWsReaction(detail) { const { messageId, reactionsSummary, chatId } = detail; if (chatId === this.chatId && messageId) { this.loader.handleReactionChange({ message_id: messageId, reactions_summary: reactionsSummary }); } } // ==================== Приватные методы - Утилиты ==================== /** * Валидирует опции конструктора * @private */ _validateOptions(options) { if (!options.chatId) { throw new Error('[ChatControllerV2] chatId is required'); } if (!options.scrollElement) { throw new Error('[ChatControllerV2] scrollElement is required'); } if (!options.currentUserId) { throw new Error('[ChatControllerV2] currentUserId is required'); } } /** * 🆕 Мгновенно позиционирует viewport на сообщение БЕЗ скроллинга * Вызывается ТОЛЬКО при начальной загрузке * @private */ async _positionToMessage(messageId) { if (!messageId) return false; // Ждем один RAF для надежности await new Promise(resolve => requestAnimationFrame(resolve)); const messageEl = this.scrollElement.querySelector(`[data-message-id="${messageId}"]`); if (messageEl) { // Вычисляем позицию для центрирования сообщения const containerRect = this.scrollElement.getBoundingClientRect(); const messageRect = messageEl.getBoundingClientRect(); const relativeTop = messageRect.top - containerRect.top; // Учитываем sticky header (56px + ~40px для sticky date) const stickyHeaderOffset = 96; const targetScrollTop = this.scrollElement.scrollTop + relativeTop - (this.scrollElement.clientHeight / 2) + (messageRect.height / 2); // Дополнительно вычитаем offset если сообщение будет в верхней части const adjustedScrollTop = relativeTop < this.scrollElement.clientHeight / 2 ? targetScrollTop - stickyHeaderOffset : targetScrollTop; // ✨ МГНОВЕННАЯ установка позиции - НЕ скролл, а начальная позиция! this.scrollElement.scrollTop = Math.max(0, adjustedScrollTop); console.log('[ChatControllerV2] Initial position set to message:', { messageId, scrollTop: this.scrollElement.scrollTop, relativeTop, noScrollAnimation: true }); return true; } console.warn('[ChatControllerV2] Message not found for positioning:', messageId); return false; } /** * Прокручивает к сообщению * @private */ async _scrollToMessage(messageId) { if (!messageId) return false; // Ждем завершения рендера await new Promise(resolve => setTimeout(resolve, SCROLL_CONFIG.SCROLL_RESTORE_DELAY)); const messageEl = this.scrollElement.querySelector(`[data-message-id="${messageId}"]`); if (messageEl) { // ✅ КРИТИЧНО: НЕ используем scrollIntoView() - он скроллит родителей! // Вместо этого вручную скроллим только scrollElement const containerRect = this.scrollElement.getBoundingClientRect(); const messageRect = messageEl.getBoundingClientRect(); const relativeTop = messageRect.top - containerRect.top; // Учитываем sticky header (56px + ~40px для sticky date) const stickyHeaderOffset = 96; const targetScrollTop = this.scrollElement.scrollTop + relativeTop - (this.scrollElement.clientHeight / 2) + (messageRect.height / 2); // Дополнительно вычитаем offset если сообщение будет в верхней части const adjustedScrollTop = relativeTop < this.scrollElement.clientHeight / 2 ? targetScrollTop - stickyHeaderOffset : targetScrollTop; // 🚫 REMOVED: Автоскролл к сообщению после loadAround // loadAround уже загрузил контекст, сообщение видно // Принудительное центрирование мешает естественной навигации console.log('[ChatControllerV2] _scrollToMessage removed - context loaded, no forced scroll'); console.log('[ChatControllerV2] Scrolled to message with sticky header adjustment:', { messageId, relativeTop, stickyHeaderOffset, targetScrollTop, adjustedScrollTop }); return true; } return false; } /** * Отправляет событие * @private */ _emit(eventName, detail) { window.dispatchEvent(new CustomEvent(eventName, { detail })); } // ==================== Умный автоскролл ==================== /** * Показывает индикатор новых сообщений * @private */ _showNewMessagesIndicator() { // Ищем или создаём кнопку if (!this._newMessagesBtn) { this._newMessagesBtn = this._findOrCreateNewMessagesButton(); } if (!this._newMessagesBtn) { console.warn('[ChatControllerV2] New messages button not found'); return; } // Увеличиваем счётчик this._newMessagesCount++; // Обновляем текст кнопки const badge = this._newMessagesBtn.querySelector('.badge'); if (badge) { badge.textContent = this._newMessagesCount; } // Показываем кнопку this._newMessagesBtn.style.display = 'flex'; this._newMessagesBtn.classList.add('show'); } /** * Скрывает индикатор новых сообщений * @private */ _hideNewMessagesIndicator() { if (!this._newMessagesBtn) { return; } // Сбрасываем счётчик this._newMessagesCount = 0; // Обновляем текст const badge = this._newMessagesBtn.querySelector('.badge'); if (badge) { badge.textContent = '0'; } // Скрываем кнопку this._newMessagesBtn.style.display = 'none'; this._newMessagesBtn.classList.remove('show'); } /** * Находит или создаёт кнопку новых сообщений * @private * @returns {HTMLElement|null} */ _findOrCreateNewMessagesButton() { // Ищем существующую кнопку let btn = document.getElementById(AUTOSCROLL_CONFIG.NEW_MESSAGES_BTN_ID); if (btn) { return btn; } // Создаём новую кнопку btn = document.createElement('button'); btn.id = AUTOSCROLL_CONFIG.NEW_MESSAGES_BTN_ID; btn.className = AUTOSCROLL_CONFIG.NEW_MESSAGES_BTN_CLASS; btn.style.display = 'none'; btn.innerHTML = ` 0 новых сообщения `; // Добавляем обработчик клика btn.addEventListener('click', () => { this.scrollManager.scrollToBottom({ instant: !AUTOSCROLL_CONFIG.SMOOTH_SCROLL_ON_CLICK, force: true }); this._hideNewMessagesIndicator(); }); // Добавляем в DOM const chatContainer = this.scrollElement.parentElement || document.body; chatContainer.appendChild(btn); return btn; } /** * Настраивает обработчик кликов на даты * @private */ _setupDateClickHandler() { // Делегированный обработчик для всех .sticky-date this.scrollElement.addEventListener('click', (e) => { const stickyDate = e.target.closest('.sticky-date.interactive'); if (!stickyDate) return; const dateText = stickyDate.getAttribute('data-date-text') || stickyDate.querySelector('span')?.textContent; console.log('[ChatControllerV2] Date clicked:', dateText); // Открываем модальное окно с date picker this._openDatePickerModal(dateText); }); } /** * Открывает модальное окно с date picker * @private */ _openDatePickerModal(currentDateText) { // Ищем или создаем модальное окно let modal = document.getElementById('chatDatePickerModal'); if (!modal) { // Создаем модальное окно динамически modal = this._createDatePickerModal(); document.body.appendChild(modal); } // Инициализируем Bootstrap modal const bsModal = new bootstrap.Modal(modal); // Устанавливаем текущую дату как заголовок const modalTitle = modal.querySelector('.modal-title'); if (modalTitle) { modalTitle.textContent = `Перейти к дате (текущая: ${currentDateText})`; } // Обработчик подтверждения const confirmBtn = modal.querySelector('#confirmDateJump'); const dateInput = modal.querySelector('#chatDateInput'); if (confirmBtn && dateInput) { // Устанавливаем сегодняшнюю дату по умолчанию dateInput.valueAsDate = new Date(); // Удаляем старые обработчики const newConfirmBtn = confirmBtn.cloneNode(true); confirmBtn.parentNode.replaceChild(newConfirmBtn, confirmBtn); // Добавляем новый обработчик newConfirmBtn.addEventListener('click', () => { const selectedDate = dateInput.valueAsDate; if (selectedDate) { bsModal.hide(); this._jumpToDate(selectedDate); } }); } // Показываем модальное окно bsModal.show(); } /** * Создает модальное окно date picker * @private */ _createDatePickerModal() { const modal = document.createElement('div'); modal.id = 'chatDatePickerModal'; modal.className = 'modal fade'; modal.tabIndex = -1; modal.setAttribute('aria-hidden', 'true'); modal.innerHTML = ` `; return modal; } /** * Переходит к указанной дате * @private */ async _jumpToDate(targetDate) { console.log('[ChatControllerV2] Jumping to date:', targetDate); // Конвертируем дату в timestamp (начало дня по UTC) const timestamp = Date.UTC( targetDate.getFullYear(), targetDate.getMonth(), targetDate.getDate() ); try { // Показываем индикатор загрузки const loader = this.scrollElement.querySelector('[data-history-loader]'); if (loader) { loader.classList.remove('d-none'); } // КРИТИЧНО: Очищаем Store чтобы не смешивать старые и новые сообщения // Как в Telegram Web - при переходе к дате чат перезагружается с этой точки this.store.clearChat(this.chatId); // Загружаем сообщения вокруг этой даты через API // Сообщения автоматически попадут в Store const loadResult = await this.loader.loadAround(this.chatId, timestamp); if (loadResult.messages && loadResult.messages.length > 0) { console.log('[ChatControllerV2] Loaded messages around date:', loadResult.messages.length); // Перерисовываем чат из Store (как в Telegram Web) await this.renderer.render(this.chatId); // КРИТИЧНО: Переинициализируем ScrollManager для новых сообщений // Сбрасываем флаг инициализации if (this.scrollManager._isInitializing !== undefined) { this.scrollManager._isInitializing = false; } // Переподключаем IntersectionObserver к новому первому сообщению if (this.scrollManager._setupIntersectionObserver) { this.scrollManager._setupIntersectionObserver(); } // Даем время на завершение рендеринга await new Promise(resolve => requestAnimationFrame(resolve)); // Якорное сообщение (возвращается API) const anchorMessageId = loadResult.anchorId || loadResult.messages[0].id; // Скроллим к якорному сообщению БЕЗ smooth (мгновенно) this.scrollManager.scrollToMessage(anchorMessageId, { block: 'start', behavior: 'auto' }); // КРИТИЧНО: Приостанавливаем обработку скролла на 1 секунду // чтобы не сработал автоматический loadNewer this.scrollManager.pauseScrollEvents(1000); console.log('[ChatControllerV2] Jumped to date, anchor message:', anchorMessageId); } else { // Нет сообщений в эту дату alert('В выбранную дату нет сообщений'); } } catch (error) { console.error('[ChatControllerV2] Error jumping to date:', error); alert('Ошибка при переходе к дате'); } finally { // Скрываем индикатор const loader = this.scrollElement.querySelector('[data-history-loader]'); if (loader) { loader.classList.add('d-none'); } } } /** * Инициализирует отслеживание скролла для индикатора * @private */ _initScrollWatcher() { // Слушаем скролл для автоматического скрытия индикатора const scrollHandler = () => { if (this.scrollManager.isNearBottom() && this._newMessagesCount > 0) { this._hideNewMessagesIndicator(); } }; this.scrollElement.addEventListener('scroll', scrollHandler); // Сохраняем handler для cleanup this._scrollWatcherCleanup = () => { this.scrollElement.removeEventListener('scroll', scrollHandler); }; } } export default ChatControllerV2;