diff --git a/backend/static/js/controllers/chatControllerV2.js b/backend/static/js/controllers/chatControllerV2.js index baa96fa7..b8e2d479 100644 --- a/backend/static/js/controllers/chatControllerV2.js +++ b/backend/static/js/controllers/chatControllerV2.js @@ -1,7 +1,7 @@ /** * @fileoverview ChatController V2 - рефакторированный координатор чата * @module controllers/chatControllerV2 - * + * * РЕФАКТОРИНГ: * - Чистая архитектура с разделением ответственности * - Минимум логирования (только важные события) @@ -10,14 +10,19 @@ * - Поддержка отмены операций */ -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 { UnreadBadgeManager } from '../managers/unreadBadgeManager.js'; -import { CHAT_EVENTS, WS_EVENTS, SCROLL_CONFIG, AUTOSCROLL_CONFIG } from '../config/chatConfig.js'; +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 { UnreadBadgeManager } from "../managers/unreadBadgeManager.js"; +import { + CHAT_EVENTS, + WS_EVENTS, + SCROLL_CONFIG, + AUTOSCROLL_CONFIG, +} from "../config/chatConfig.js"; /** * @typedef {Object} ChatControllerOptions @@ -36,830 +41,888 @@ import { CHAT_EVENTS, WS_EVENTS, SCROLL_CONFIG, AUTOSCROLL_CONFIG } from '../con * Единая точка входа для работы с чатом. */ 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; + /** + * @param {ChatControllerOptions} options + */ + constructor(options) { + this._validateOptions(options); - // Создаем компоненты - 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.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.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.loader = new MessageLoaderV2({ + store: this.store, + wsConnection: options.wsConnection, + currentUserId: this.currentUserId, + }); - this.scrollManager = new ScrollManagerV2({ - scrollElement: this.scrollElement, - loader: this.loader, - renderer: this.renderer, - store: this.store, - chatId: this.chatId - }); + 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._initialized = false; - this._initializing = false; - this._destroyed = false; - - // Badge Manager для непрочитанных сообщений - this.badgeManager = new UnreadBadgeManager('scrollBtn'); - - // Слушатели - this._scrollWatcherCleanup = null; - - // Слушатели для cleanup - this._eventListeners = []; + this.scrollManager = new ScrollManagerV2({ + scrollElement: this.scrollElement, + loader: this.loader, + renderer: this.renderer, + store: this.store, + chatId: this.chatId, + }); - // Подписки - this._setupStoreSubscription(); - if (options.wsConnection) { - this._setupWebSocketListeners(); - } + // Состояние + this._initialized = false; + this._initializing = false; + this._destroyed = false; - console.log('[ChatControllerV2] Created for chat:', this.chatId); + // Badge Manager для непрочитанных сообщений + this.badgeManager = new UnreadBadgeManager("scrollBtn"); + + // Слушатели + this._scrollWatcherCleanup = null; + + // Слушатели для cleanup + this._eventListeners = []; + + // Подписки + this._setupStoreSubscription(); + if (options.wsConnection) { + this._setupWebSocketListeners(); } - // ==================== Публичное API - Жизненный цикл ==================== + console.log("[ChatControllerV2] Created for chat:", this.chatId); + } - /** - * Инициализирует чат: загружает сообщения, рендерит, настраивает скролл - * @returns {Promise} - */ - async init() { - if (this._initialized) { - console.warn('[ChatControllerV2] Already initialized'); - return; - } - - if (this._initializing) { - console.warn('[ChatControllerV2] Initialization in progress'); - return; - } + // ==================== Публичное API - Жизненный цикл ==================== - 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. Инициализируем умную кнопку скролла - this._initScrollButton(); - - // 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; - } + /** + * Инициализирует чат: загружает сообщения, рендерит, настраивает скролл + * @returns {Promise} + */ + async init() { + if (this._initialized) { + console.warn("[ChatControllerV2] Already initialized"); + return; } - /** - * Уничтожает контроллер и освобождает ресурсы - */ - 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'); + if (this._initializing) { + console.warn("[ChatControllerV2] Initialization in progress"); + return; } - // ==================== Публичное API - Сообщения ==================== + this._initializing = true; - /** - * Отправляет сообщение - * @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; - } + try { + // 1. Загружаем сообщения + // Используем timestamp как fallback если нет message ID (API поддерживает оба) + const anchorValue = this.lastReadMessageId || this.lastReadTimestamp; + const loadResult = await this.loader.loadInitial(this.chatId, { + aroundMessageId: anchorValue, + }); - const tempId = this.loader.sendOptimistic(this.chatId, content.trim(), options); - return tempId; - } + // 2. Скрываем контейнер для плавного рендера + this.scrollElement.classList.add("scroll-hidden"); - /** - * Получает сообщение по ID - * @param {number|string} messageId - * @returns {Object|null} - */ - getMessage(messageId) { - return this.store.getMessage(messageId); - } + try { + // 3. Рендерим сообщения + await this.renderer.render(this.chatId); - /** - * Получает все сообщения чата - * @param {Object} [options] - * @returns {Array} - */ - getMessages(options = {}) { - return this.store.getMessagesForChat(this.chatId, options); - } + // 4. СНАЧАЛА показываем контейнер (важно для scrollTop!) + this.scrollElement.classList.remove("scroll-hidden"); - // ==================== Публичное API - Скролл ==================== + // Ждем один frame чтобы браузер применил visibility + await new Promise((resolve) => requestAnimationFrame(resolve)); - /** - * Загружает больше истории - * @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 }); - }); - - // Слушаем событие скролла вниз для сброса badge - const scrollBottomHandler = (e) => { - if (e.detail.chatId === this.chatId) { - this.badgeManager.reset(); - } - }; - window.addEventListener('chat:userScrolledToBottom', scrollBottomHandler); - this._eventListeners.push({ event: 'chat:userScrolledToBottom', handler: scrollBottomHandler }); - } - - /** - * @private - */ - _handleWsNewMessage(detail) { - const { message, chatId } = detail; - if (chatId === this.chatId && message) { - const boundaries = this.loader.chatBoundaries.get(chatId); - const isInPresentTime = boundaries && !boundaries.hasMoreAfter; - - if (isInPresentTime) { - // ✅ Настоящее время (hasMoreAfter=false) - обрабатываем как обычно - this.loader.handleNewMessage(message); - - const isNearBottom = this.scrollManager.isNearBottom(); - - if (isNearBottom) { - // ✅ Пользователь внизу → автоскролл к новому сообщению - requestAnimationFrame(() => { - this.scrollElement.scrollTop = this.scrollElement.scrollHeight; - console.log('[ChatControllerV2] Auto-scrolled to new message (user at bottom)'); - }); - } else { - // ✅ Пользователь НЕ внизу → показываем badge - this.badgeManager.increment(); - } - } else { - // ❌ Исторический просмотр (hasMoreAfter=true) - НЕ рендерим, только badge - // Не добавляем в Store (не рендерим), только показываем что есть новые - this.badgeManager.increment(); - console.log('[ChatControllerV2] New message in historical view - showing badge only'); - } - } - } - - /** - * @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 })); - } - - // ==================== Умный scrollBtn с badge ==================== - - /** - * Инициализирует умную кнопку скролла - * @private - */ - _initScrollButton() { - const scrollBtn = document.getElementById('scrollBtn'); - - if (!scrollBtn) { - console.warn('[ChatControllerV2] #scrollBtn not found'); - return; - } - - // Заменяем обработчик клика на умный - scrollBtn.onclick = null; - scrollBtn.addEventListener('click', () => this._handleScrollButtonClick()); - - // Настраиваем умную видимость - this._setupSmartButtonVisibility(); - - console.log('[ChatControllerV2] Smart scroll button initialized'); - } - - /** - * Настраивает умную видимость кнопки - * @private - */ - _setupSmartButtonVisibility() { - const scrollBtn = document.getElementById('scrollBtn'); - if (!scrollBtn) return; - - // Обновляем видимость при скролле - const updateVisibility = () => { - const boundaries = this.loader.chatBoundaries.get(this.chatId); - const hasMoreAfter = boundaries?.hasMoreAfter || false; - const isNearBottom = this.scrollManager.isNearBottom(); - - // Показываем кнопку если: - // 1. Пользователь НЕ внизу (любой случай) - // 2. ИЛИ hasMoreAfter=true (даже если внизу) - const shouldShow = !isNearBottom || hasMoreAfter; - - scrollBtn.classList.toggle('show', shouldShow); - }; - - // Обновляем при скролле - this.scrollElement.addEventListener('scroll', updateVisibility); - - // Обновляем при загрузке новых сообщений - window.addEventListener('chat:messagesLoaded', updateVisibility); - - // Начальное обновление - setTimeout(updateVisibility, 100); - } - - /** - * Обработчик клика на умную кнопку - * @private - */ - async _handleScrollButtonClick() { - const boundaries = this.loader.chatBoundaries.get(this.chatId); - const hasMoreAfter = boundaries?.hasMoreAfter || false; - - if (hasMoreAfter) { - // 📥 ИСТОРИЧЕСКИЙ НИЗ: Загружаем всю историю до конца - console.log('[ChatControllerV2] Loading history to present time...'); - - try { - // Загружаем последние сообщения (настоящее время) - const result = await this.loader.loadLatest(this.chatId, 50); - - // Рендерим новые сообщения - await this.renderer.render(this.chatId); - - // Скроллим в низ - await new Promise(resolve => requestAnimationFrame(resolve)); - this.scrollElement.scrollTop = this.scrollElement.scrollHeight; - - console.log('[ChatControllerV2] Loaded to present, messages:', result.messages.length); - } catch (error) { - console.error('[ChatControllerV2] Failed to load history:', error); - // Если ошибка - просто скроллим в низ загруженного - this.scrollElement.scrollTop = this.scrollElement.scrollHeight; - } - - // Сбрасываем badge - this.badgeManager.reset(); - return; - } - - // 🔵 НАСТОЯЩИЙ НИЗ: Умная логика - const isInPresentTime = !hasMoreAfter; - - if (this.badgeManager.hasUnread() && this.lastReadMessageId && isInPresentTime) { - // Режим "Перейти к непрочитанным" - // Пытаемся найти первое непрочитанное (lastReadId + 1) - const firstUnreadId = this.lastReadMessageId + 1; - const firstUnreadEl = this.scrollElement.querySelector(`[data-message-id="${firstUnreadId}"]`); - - if (firstUnreadEl) { - // Скроллим к первому непрочитанному - const containerRect = this.scrollElement.getBoundingClientRect(); - const messageRect = firstUnreadEl.getBoundingClientRect(); - const relativeTop = messageRect.top - containerRect.top; - const targetScrollTop = this.scrollElement.scrollTop + relativeTop - 100; - - this.scrollElement.scrollTop = Math.max(0, targetScrollTop); - console.log('[ChatControllerV2] Scrolled to first unread:', firstUnreadId); - } else { - // Не нашли первое непрочитанное - просто в низ - this.scrollElement.scrollTop = this.scrollElement.scrollHeight; - } + // 5. ✨ НОВЫЙ ПОДХОД: Мгновенное позиционирование БЕЗ скролла + if (loadResult.anchorId) { + // Есть lastRead - СРАЗУ позиционируем на него + await this._positionToMessage(loadResult.anchorId); } else { - // Обычный режим "В самый низ" - this.scrollElement.scrollTop = this.scrollElement.scrollHeight; - console.log('[ChatControllerV2] Scrolled to bottom'); - } - - // Сбрасываем badge - this.badgeManager.reset(); - } - - - - - - /** - * Настраивает обработчик кликов на даты - * @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); - } + // Нет anchor - позиционируем в самый низ (новый чат) + await new Promise((resolve) => { + requestAnimationFrame(() => { + this.scrollElement.scrollTop = this.scrollElement.scrollHeight; + console.log( + "[ChatControllerV2] Initial position: bottom (no anchor)" + ); + resolve(); }); + }); } - // Показываем модальное окно - bsModal.show(); + // 6. Инициализируем ScrollManager + await this.scrollManager.init(); + + // 7. Инициализируем StickyDateObserver + this.stickyDateObserver.init(); + + // 8. Настраиваем обработчик кликов на даты + this._setupDateClickHandler(); + + // 9. Инициализируем умную кнопку скролла + this._initScrollButton(); + + // 9. Инициализируем watcher для умного автоскролла + this._initScrollWatcher(); + } catch (error) { + this.scrollElement.classList.remove("scroll-hidden"); + 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; } - /** - * Создает модальное окно 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 = ` + // Удаляем слушатели событий + 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 }); + }); + + // Слушаем событие скролла вниз для сброса badge + const scrollBottomHandler = (e) => { + if (e.detail.chatId === this.chatId) { + this.badgeManager.reset(); + } + }; + window.addEventListener("chat:userScrolledToBottom", scrollBottomHandler); + this._eventListeners.push({ + event: "chat:userScrolledToBottom", + handler: scrollBottomHandler, + }); + } + + /** + * @private + */ + _handleWsNewMessage(detail) { + const { message, chatId } = detail; + if (chatId === this.chatId && message) { + const boundaries = this.loader.chatBoundaries.get(chatId); + const isInPresentTime = boundaries && !boundaries.hasMoreAfter; + + if (isInPresentTime) { + // ✅ Настоящее время (hasMoreAfter=false) - обрабатываем как обычно + this.loader.handleNewMessage(message); + + const isNearBottom = this.scrollManager.isNearBottom(); + + if (isNearBottom) { + // ✅ Пользователь внизу → автоскролл к новому сообщению + requestAnimationFrame(() => { + this.scrollElement.scrollTop = this.scrollElement.scrollHeight; + console.log( + "[ChatControllerV2] Auto-scrolled to new message (user at bottom)" + ); + }); + } else { + // ✅ Пользователь НЕ внизу → показываем badge + this.badgeManager.increment(); + } + } else { + // ❌ Исторический просмотр (hasMoreAfter=true) - НЕ рендерим, только badge + // Не добавляем в Store (не рендерим), только показываем что есть новые + this.badgeManager.increment(); + console.log( + "[ChatControllerV2] New message in historical view - showing badge only" + ); + } + } + } + + /** + * @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 })); + } + + // ==================== Умный scrollBtn с badge ==================== + + /** + * Инициализирует умную кнопку скролла + * @private + */ + _initScrollButton() { + const scrollBtn = document.getElementById("scrollBtn"); + + if (!scrollBtn) { + console.warn("[ChatControllerV2] #scrollBtn not found"); + return; + } + + // Заменяем обработчик клика на умный + scrollBtn.onclick = null; + scrollBtn.addEventListener("click", () => this._handleScrollButtonClick()); + + // Настраиваем умную видимость + this._setupSmartButtonVisibility(); + + console.log("[ChatControllerV2] Smart scroll button initialized"); + } + + /** + * Настраивает умную видимость кнопки + * @private + */ + _setupSmartButtonVisibility() { + const scrollBtn = document.getElementById("scrollBtn"); + if (!scrollBtn) return; + + // Обновляем видимость при скролле + const updateVisibility = () => { + const boundaries = this.loader.chatBoundaries.get(this.chatId); + const hasMoreAfter = boundaries?.hasMoreAfter || false; + const isNearBottom = this.scrollManager.isNearBottom(); + + // Показываем кнопку если: + // 1. Пользователь НЕ внизу (любой случай) + // 2. ИЛИ hasMoreAfter=true (даже если внизу) + const shouldShow = !isNearBottom || hasMoreAfter; + + scrollBtn.classList.toggle("show", shouldShow); + }; + + // Обновляем при скролле + this.scrollElement.addEventListener("scroll", updateVisibility); + + // Обновляем при загрузке новых сообщений + window.addEventListener("chat:messagesLoaded", updateVisibility); + + // Начальное обновление + setTimeout(updateVisibility, 100); + } + + /** + * Обработчик клика на умную кнопку + * @private + */ + async _handleScrollButtonClick() { + const boundaries = this.loader.chatBoundaries.get(this.chatId); + const hasMoreAfter = boundaries?.hasMoreAfter || false; + + if (hasMoreAfter) { + // 📥 ИСТОРИЧЕСКИЙ НИЗ: Загружаем всю историю до конца + console.log("[ChatControllerV2] Loading history to present time..."); + + try { + // Загружаем последние сообщения (настоящее время) + const result = await this.loader.loadLatest(this.chatId, 50); + + // Рендерим новые сообщения + await this.renderer.render(this.chatId); + + // Скроллим в низ + await new Promise((resolve) => requestAnimationFrame(resolve)); + this.scrollElement.scrollTop = this.scrollElement.scrollHeight; + + console.log( + "[ChatControllerV2] Loaded to present, messages:", + result.messages.length + ); + } catch (error) { + console.error("[ChatControllerV2] Failed to load history:", error); + // Если ошибка - просто скроллим в низ загруженного + this.scrollElement.scrollTop = this.scrollElement.scrollHeight; + } + + // Сбрасываем badge + this.badgeManager.reset(); + return; + } + + // 🔵 НАСТОЯЩИЙ НИЗ: Умная логика + const isInPresentTime = !hasMoreAfter; + + if ( + this.badgeManager.hasUnread() && + this.lastReadMessageId && + isInPresentTime + ) { + // Режим "Перейти к непрочитанным" + // Пытаемся найти первое непрочитанное (lastReadId + 1) + const firstUnreadId = this.lastReadMessageId + 1; + const firstUnreadEl = this.scrollElement.querySelector( + `[data-message-id="${firstUnreadId}"]` + ); + + if (firstUnreadEl) { + // Скроллим к первому непрочитанному + const containerRect = this.scrollElement.getBoundingClientRect(); + const messageRect = firstUnreadEl.getBoundingClientRect(); + const relativeTop = messageRect.top - containerRect.top; + const targetScrollTop = + this.scrollElement.scrollTop + relativeTop - 100; + + this.scrollElement.scrollTop = Math.max(0, targetScrollTop); + console.log( + "[ChatControllerV2] Scrolled to first unread:", + firstUnreadId + ); + } else { + // Не нашли первое непрочитанное - просто в низ + this.scrollElement.scrollTop = this.scrollElement.scrollHeight; + } + } else { + // Обычный режим "В самый низ" + this.scrollElement.scrollTop = this.scrollElement.scrollHeight; + console.log("[ChatControllerV2] Scrolled to bottom"); + } + + // Сбрасываем badge + this.badgeManager.reset(); + } + + /** + * Настраивает обработчик кликов на даты + * @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 = `