/** * @fileoverview ChatController - главный координатор чата * @module controllers/chatController * * Единая точка входа для работы с чатом. * Координирует все компоненты: Store, Loader, Renderer, ScrollManager */ import { MessageStore } from '../stores/messageStore.js'; import { MessageLoader } from '../loaders/messageLoader.js'; import { MessageRendererV2 } from '../renderers/messageRendererV2.js'; import { ScrollManager } from '../managers/scrollManager.js'; /** * ChatController - координатор чата */ export class ChatController { /** * @param {Object} options - Опции конфигурации * @param {number} options.chatId - ID чата * @param {HTMLElement} options.scrollElement - Контейнер для скролла * @param {string} options.containerId - ID контейнера сообщений * @param {number} options.currentUserId - ID текущего пользователя * @param {Object} options.wsConnection - WebSocket соединение * @param {string} [options.profileUrl] - URL профиля * @param {string} [options.detailUrlTemplate] - URL детальной информации * @param {number} [options.lastReadTimestamp] - Timestamp последнего прочитанного сообщения */ constructor(options) { if (!options.chatId) { throw new Error('[ChatController] chatId is required'); } if (!options.scrollElement) { throw new Error('[ChatController] scrollElement is required'); } if (!options.currentUserId) { throw new Error('[ChatController] currentUserId is required'); } this.chatId = options.chatId; this.currentUserId = options.currentUserId; this.scrollElement = options.scrollElement; this.scrollEl = options.scrollElement; // alias this.containerId = options.containerId || 'chatScroll'; this.lastReadTimestamp = options.lastReadTimestamp || null; console.log('[ChatController] Initializing for chat:', this.chatId); // Создаем компоненты this.store = new MessageStore({ currentUserId: this.currentUserId }); this.loader = new MessageLoader({ store: this.store, wsConnection: options.wsConnection, currentUserId: this.currentUserId }); this.renderer = new MessageRendererV2({ store: this.store, containerId: this.containerId, currentUserId: this.currentUserId, profileUrl: options.profileUrl, detailUrlTemplate: options.detailUrlTemplate }); this.scrollManager = new ScrollManager({ scrollElement: this.scrollElement, messageLoader: this.loader, messageRenderer: this.renderer, messageStore: this.store, chatId: this.chatId }); // Флаг инициализации this.initialized = false; this.isInitializing = false; // Подписываемся на изменения Store this._subscribeToStore(); // Подписываемся на WebSocket (если есть) if (options.wsConnection) { this._subscribeToWebSocket(options.wsConnection); } console.log('[ChatController] Created'); } // ==================== Инициализация ==================== /** * Инициализирует чат (загружает сообщения, рендерит, скроллит) * @param {Object} options - Опции * @param {boolean} [options.scrollToBottom=true] - Прокрутить вниз после загрузки * @returns {Promise} */ async init(options = {}) { if (this.initialized) { console.warn('[ChatController] Already initialized'); return; } try { console.log('[ChatController] ========================================'); console.log('[ChatController] Initializing chat:', this.chatId); console.log('[ChatController] lastReadTimestamp:', this.lastReadTimestamp); console.log('[ChatController] lastReadTimestamp type:', typeof this.lastReadTimestamp); console.log('[ChatController] ========================================'); // Устанавливаем флаг что идет начальная загрузка this.isInitializing = true; // 1. Загружаем начальные сообщения ВОКРУГ last_read console.log('[ChatController] Step 1: Loading messages around last read...'); const loadResult = await this.loader.loadInitialMessages(this.chatId, { lastReadMessageId: this.lastReadTimestamp // Используем как ID (нужно переименовать) }); console.log('[ChatController] ========================================'); console.log('[ChatController] Load result:', loadResult); console.log('[ChatController] ========================================'); // 2. Скрываем контейнер чтобы избежать видимых прыжков console.log('[ChatController] Step 2: Hiding container for smooth render...'); this.scrollEl.style.visibility = 'hidden'; try { // 3. Рендерим (пользователь не видит процесс) console.log('[ChatController] Step 3: Rendering messages...'); await this.renderer.render(this.chatId); // 4. ВАЖНО: Показываем контейнер ДО скролла (scrollTop не работает на hidden) console.log('[ChatController] Step 4: Showing container...'); this.scrollEl.style.visibility = ''; // 5. Прокручиваем к нужной позиции ПОСЛЕ показа контейнера console.log('[ChatController] Step 5: Scrolling to position...'); console.log('[ChatController] Anchor ID from loadResult:', loadResult.anchorId); if (loadResult.anchorId) { // Есть anchor - пытаемся скроллить к нему const scrolled = await this._scrollToMessage(loadResult.anchorId); console.log('[ChatController] Scrolled to anchor message:', loadResult.anchorId, '→', scrolled); if (!scrolled) { // Не нашли anchor - оставляем прокрутку как есть (не скроллим автоматически) console.log('[ChatController] Anchor not found, keeping scroll position'); } } else { // Нет anchor (нет lastReadId) - оставляем прокрутку как есть (НЕ скроллим автоматически вниз) console.log('[ChatController] No anchor, keeping scroll position'); } } catch (scrollError) { console.error('[ChatController] ❌ Error during init:', scrollError); // В случае ошибки всё равно показываем контейнер this.scrollEl.style.visibility = ''; throw scrollError; } // 6. Инициализируем ScrollManager ПОСЛЕ скролла console.log('[ChatController] Step 6: Initializing ScrollManager...'); await this.scrollManager.init(); this.initialized = true; this.isInitializing = false; // Сбрасываем флаг console.log('[ChatController] ✅ Initialization complete'); // Dispatch события для других компонентов this._dispatchEvent('chat:initialized', { chatId: this.chatId }); } catch (error) { console.error('[ChatController] ❌ Initialization failed:', error); throw error; } } /** * Прокручивает к конкретному сообщению * @private * @param {number} messageId - ID сообщения * @param {number} [offset=100] - Отступ сверху в пикселях * @returns {Promise} true если нашли и прокрутили */ async _scrollToMessage(messageId, offset = 100) { console.log('[ChatController] ========================================'); console.log('[ChatController] _scrollToMessage called'); console.log('[ChatController] messageId:', messageId); console.log('[ChatController] offset:', offset); if (!messageId) { console.log('[ChatController] No messageId provided'); console.log('[ChatController] ========================================'); return false; } // Даём время на завершение DOM updates и browser reflow await new Promise(resolve => setTimeout(resolve, 150)); // Ищем элемент в DOM const messageEl = this.scrollEl.querySelector(`[data-message-id="${messageId}"]`); console.log('[ChatController] Found element:', !!messageEl); if (messageEl) { console.log('[ChatController] Element classes:', messageEl.className); // Используем scrollIntoView - надежнее чем ручной scrollTop messageEl.scrollIntoView({ behavior: 'instant', // без анимации block: 'center', // в центр viewport inline: 'nearest' }); console.log('[ChatController] ✅ Scrolled to message:', messageId); console.log('[ChatController] ========================================'); return true; } if (!messageEl) { console.warn('[ChatController] Message element not found in DOM:', messageId); console.log('[ChatController] Available message IDs:', Array.from(this.scrollEl.querySelectorAll('[data-message-id]')) .map(el => el.dataset.messageId).slice(0, 10) ); console.log('[ChatController] ========================================'); return false; } // Прокручиваем к элементу с отступом сверху const offsetTop = messageEl.offsetTop - offset; const scrollBefore = this.scrollEl.scrollTop; this.scrollEl.scrollTop = Math.max(0, offsetTop); const scrollAfter = this.scrollEl.scrollTop; console.log('[ChatController] Scroll details:'); console.log('[ChatController] Element offsetTop:', messageEl.offsetTop); console.log('[ChatController] Target scrollTop:', offsetTop); console.log('[ChatController] Scroll before:', scrollBefore); console.log('[ChatController] Scroll after:', scrollAfter); console.log('[ChatController] ✅ Scrolled to message', messageId); console.log('[ChatController] ========================================'); return true; } /** * Уничтожает контроллер и освобождает ресурсы */ destroy() { console.log('[ChatController] Destroying...'); // Останавливаем ScrollManager if (this.scrollManager) { this.scrollManager.destroy(); } // Очищаем Store if (this.store) { this.store.clearChat(this.chatId); } // Очищаем рендерер if (this.renderer) { this.renderer.clear(); } this.initialized = false; console.log('[ChatController] Destroyed'); } // ==================== Подписки ==================== /** * Подписывается на изменения Store * @private */ _subscribeToStore() { this.store.subscribe((event, data) => { this._handleStoreUpdate(event, data); }); } /** * Обрабатывает обновления из Store * @private */ _handleStoreUpdate(event, data) { console.log('[ChatController] Store update:', event, data); switch (event) { case 'message_added': this._handleMessageAdded(data); break; case 'message_updated': this._handleMessageUpdated(data); break; case 'message_removed': this._handleMessageRemoved(data); break; case 'messages_loaded': // Batch загрузка - ничего не делаем, render() вызовется вручную break; default: console.log('[ChatController] Unhandled store event:', event); } } /** * Обрабатывает добавление сообщения * @private */ _handleMessageAdded(data) { const { message, optimistic } = data; // ВАЖНО: Игнорируем события во время начальной загрузки // Начальные сообщения рендерятся через render(), а не через appendMessage if (this.isInitializing) { console.log('[ChatController] Ignoring message_added during initialization:', message.id); return; } if (message.chat_id !== this.chatId) { // Сообщение для другого чата return; } // Incremental render - добавляем только новое сообщение const wasEmpty = this.renderer.renderedMessages.size === 0; this.renderer.appendMessage(message, this.chatId); // Если это первое сообщение, настраиваем Observer if (wasEmpty) { this.scrollManager.setupIntersectionObserver(); } // Автоскролл ТОЛЬКО если это наше сообщение (отправили мы) // Убрали автоскролл при получении чужих сообщений даже если пользователь внизу const shouldScroll = message.author_id === this.currentUserId; if (shouldScroll && !optimistic) { // Плавная прокрутка к нашему новому сообщению requestAnimationFrame(() => { this.scrollEl.scrollTop = this.scrollEl.scrollHeight; }); } // Dispatch события для других компонентов (reactions, context menu, etc.) this._dispatchEvent('chat:message-added', { messageId: message.id, chatId: this.chatId }); } /** * Обрабатывает обновление сообщения * @private */ _handleMessageUpdated(data) { const { messageId, updates } = data; // Патчим DOM this.renderer.updateMessage(messageId, updates); // Dispatch события this._dispatchEvent('chat:message-updated', { messageId, updates, chatId: this.chatId }); } /** * Обрабатывает удаление сообщения * @private */ _handleMessageRemoved(data) { const { messageId } = data; // Удаляем из DOM this.renderer.removeMessage(messageId); // Dispatch события this._dispatchEvent('chat:message-removed', { messageId, chatId: this.chatId }); } /** * Подписывается на события WebSocket * @private */ _subscribeToWebSocket(ws) { // Используем глобальные события вместо прямой подписки // Это позволяет userWebSocket оставаться независимым // ВАЖНО: ws:initial-messages НЕ обрабатываем! // Initial messages загружаются через HTTP в init() // Если обрабатывать WS initial - получим двойной render и прыжки // Новое сообщение window.addEventListener('ws:new-message', (event) => { const { message, chatId } = event.detail; if (chatId === this.chatId && message) { console.log('[ChatController] WS: new-message, id=%s', message.id); this.loader.handleNewMessage(message); } }); // Редактирование сообщения window.addEventListener('ws:message-edited', (event) => { const { message, chatId } = event.detail; if (chatId === this.chatId && message) { console.log('[ChatController] WS: message-edited, id=%s', message.id); this.loader.handleMessageEdited({ message }); } }); // Удаление сообщения window.addEventListener('ws:message-removed', (event) => { const { messageId, chatId } = event.detail; if (chatId === this.chatId && messageId) { console.log('[ChatController] WS: message-removed, id=%s', messageId); this.loader.handleMessageRemoved({ message_id: messageId }); } }); // Добавление реакции window.addEventListener('ws:reaction-added', (event) => { const { messageId, reactionsSummary, chatId } = event.detail; if (chatId === this.chatId && messageId) { console.log('[ChatController] WS: reaction-added, msg=%s', messageId); this.loader.handleReactionAdded({ message_id: messageId, reactions_summary: reactionsSummary }); } }); // Удаление реакции window.addEventListener('ws:reaction-removed', (event) => { const { messageId, reactionsSummary, chatId } = event.detail; if (chatId === this.chatId && messageId) { console.log('[ChatController] WS: reaction-removed, msg=%s', messageId); this.loader.handleReactionRemoved({ message_id: messageId, reactions_summary: reactionsSummary }); } }); console.log('[ChatController] ✅ Subscribed to WebSocket events'); } // ==================== Публичное API ==================== /** * Отправляет сообщение * @param {string} content - Содержимое сообщения * @param {Object} options - Дополнительные опции * @returns {string} Временный ID сообщения */ sendMessage(content, options = {}) { if (!content || typeof content !== 'string' || content.trim() === '') { console.warn('[ChatController] Cannot send empty message'); return null; } console.log('[ChatController] Sending message...'); const tempId = this.loader.sendMessageOptimistically( this.chatId, content.trim(), options ); return tempId; } /** * Загружает больше истории * @returns {Promise} */ async loadMoreHistory() { return this.scrollManager.loadMoreHistory(); } /** * Прокручивает к последнему сообщению * @param {Object} options - Опции */ scrollToBottom(options = {}) { this.scrollManager.scrollToBottom(options); } /** * Прокручивает к конкретному сообщению * @param {number|string} messageId - ID сообщения * @param {Object} options - Опции */ scrollToMessage(messageId, options = {}) { this.scrollManager.scrollToMessage(messageId, options); } /** * Получает сообщение по ID * @param {number|string} messageId - ID сообщения * @returns {Message|null} */ getMessage(messageId) { return this.store.getMessage(messageId); } /** * Получает все сообщения чата * @param {Object} options - Опции * @returns {Array} */ getMessages(options = {}) { return this.store.getMessagesForChat(this.chatId, options); } /** * Проверяет находится ли пользователь внизу * @returns {boolean} */ isNearBottom() { return this.scrollManager.isNearBottom(); } /** * Получает статус чата * @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() }; } // ==================== Утилиты ==================== /** * Отправляет кастомное событие * @private */ _dispatchEvent(eventName, detail) { window.dispatchEvent(new CustomEvent(eventName, { detail })); } } // Экспорт для использования в других модулях export default ChatController;