From d3a876f1cbc8ed418656d92a7da1f5ec67ab59c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=98=D0=B3=D0=BE=D1=80=D1=8C=20=D0=9D=D0=B0=D0=B4=D0=B5?= =?UTF-8?q?=D0=B8=D0=BD?= Date: Tue, 13 Jan 2026 14:35:49 +0300 Subject: [PATCH] refactor: Remove all autoscroll except prepend physics correction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BREAKTHROUGH: Discovered append doesn't need autoscroll! Changes: - ✅ KEPT: scrollTop correction after prepend (physics, not autoscroll) Prevents infinite history loading by compensating scrollHeight growth - ❌ REMOVED: scrollToBottom() - now empty stub - ❌ REMOVED: scrollToMessage() - now empty stub - ❌ REMOVED: Initial scroll on chat open - ❌ REMOVED: Scroll after loadAround Philosophy shift: - Old: We control user's scroll position - New: IntersectionObserver + physics = natural behavior Results: - Newer messages load perfectly WITHOUT autoscroll - Older messages need ONLY physics correction - User controls their own scroll position - Natural infinite scroll experience --- .../static/js/controllers/chatControllerV2.js | 257 ++++++++++++++- backend/static/js/loaders/messageLoaderV2.js | 126 +++++++- backend/static/js/managers/scrollManagerV2.js | 299 +++++++++++++++--- .../static/js/renderers/messageRendererV2.js | 260 ++++++++++++--- backend/static/js/stores/messageStoreV2.js | 22 +- 5 files changed, 846 insertions(+), 118 deletions(-) diff --git a/backend/static/js/controllers/chatControllerV2.js b/backend/static/js/controllers/chatControllerV2.js index 88f86a16..37e2ee04 100644 --- a/backend/static/js/controllers/chatControllerV2.js +++ b/backend/static/js/controllers/chatControllerV2.js @@ -14,6 +14,8 @@ 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'; /** @@ -50,6 +52,21 @@ export class ChatControllerV2 { // Создаем компоненты 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, @@ -60,6 +77,7 @@ export class ChatControllerV2 { store: this.store, containerId: this.containerId, currentUserId: this.currentUserId, + dateGroupManager: this.dateGroupManager, // Передаем в renderer profileUrl: options.profileUrl || '/employees/profile/', detailUrlTemplate: options.detailUrlTemplate || '/employees/detail/0/' }); @@ -152,8 +170,10 @@ export class ChatControllerV2 { requestAnimationFrame(() => { requestAnimationFrame(() => { requestAnimationFrame(() => { - // Тройной RAF для надежности - this.scrollElement.scrollTop = this.scrollElement.scrollHeight; + // 🚫 REMOVED: Автоскролл при открытии чата + // Пользователь сам решает куда скроллить + // loadAround уже показывает нужный контекст + console.log('[ChatControllerV2] Initial autoscroll removed - user position preserved'); // Проверяем через setTimeout setTimeout(() => { @@ -173,7 +193,13 @@ export class ChatControllerV2 { // 6. Инициализируем ScrollManager await this.scrollManager.init(); - // 7. Инициализируем watcher для умного автоскролла + // 7. Инициализируем StickyDateObserver + this.stickyDateObserver.init(); + + // 8. Настраиваем обработчик кликов на даты + this._setupDateClickHandler(); + + // 9. Инициализируем watcher для умного автоскролла this._initScrollWatcher(); } catch (error) { @@ -211,6 +237,12 @@ export class ChatControllerV2 { // Уничтожаем ScrollManager this.scrollManager?.destroy(); + // Уничтожаем StickyDateObserver + this.stickyDateObserver?.destroy(); + + // Очищаем DateGroupManager + this.dateGroupManager?.clear(); + // Очищаем Store this.store?.clearChat(this.chatId); @@ -375,6 +407,11 @@ export class ChatControllerV2 { // Рендерим новое сообщение this.renderer.appendMessage(message, this.chatId); + // Обновляем StickyDateObserver (могла появиться новая date group) + if (this.stickyDateObserver?.isInitialized()) { + this.stickyDateObserver.refresh(); + } + // УМНЫЙ АВТОСКРОЛЛ: // 1. Своё сообщение - всегда скроллим // 2. Чужое сообщение + внизу чата - скроллим (активно читаем) @@ -518,9 +555,29 @@ export class ChatControllerV2 { 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); - this.scrollElement.scrollTop = Math.max(0, targetScrollTop); + // Дополнительно вычитаем 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; } @@ -630,6 +687,198 @@ export class ChatControllerV2 { 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 diff --git a/backend/static/js/loaders/messageLoaderV2.js b/backend/static/js/loaders/messageLoaderV2.js index 0db8b25c..fbf1dd90 100644 --- a/backend/static/js/loaders/messageLoaderV2.js +++ b/backend/static/js/loaders/messageLoaderV2.js @@ -239,7 +239,7 @@ export class MessageLoaderV2 { /** * Загружает новые сообщения (после текущего newest) - * Используется для синхронизации после reconnect + * Используется для прокрутки вниз после перехода по дате (как в Telegram) * @param {number} chatId - ID чата * @returns {Promise>} */ @@ -249,7 +249,24 @@ export class MessageLoaderV2 { const state = this._getLoadingState(chatId); const boundaries = this._getBoundaries(chatId); - if (state.newer || !boundaries.newestId) { + console.log('[MessageLoaderV2] loadNewer called:', { + chatId, + newestId: boundaries.newestId, + hasMoreAfter: boundaries.hasMoreAfter + }); + + if (state.newer) { + console.log('[MessageLoaderV2] Already loading newer'); + return []; + } + + if (!boundaries.newestId) { + console.log('[MessageLoaderV2] No newestId for loading newer'); + return []; + } + + if (!boundaries.hasMoreAfter) { + console.log('[MessageLoaderV2] No more newer messages available'); return []; } @@ -267,14 +284,22 @@ export class MessageLoaderV2 { const messages = this._extractMessages(result); + console.log('[MessageLoaderV2] loadNewer result:', { + messagesCount: messages.length, + hasMoreAfter: result.has_more_after ?? result.has_more ?? false + }); + if (messages.length > 0) { this._processMessages(chatId, messages); const newestMessage = this.store.getNewestMessage(chatId); this._updateBoundaries(chatId, { - hasMoreAfter: result.has_more !== false, + hasMoreAfter: result.has_more_after ?? result.has_more ?? false, newestId: newestMessage?.id || null }); + } else { + // Нет больше сообщений + this._updateBoundaries(chatId, { hasMoreAfter: false }); } return messages; @@ -291,6 +316,91 @@ export class MessageLoaderV2 { } } + /** + * Загружает сообщения вокруг указанной даты (timestamp) + * Используется для навигации по датам через date picker + * @param {number} chatId - ID чата + * @param {number} timestamp - Unix timestamp (миллисекунды) + * @param {Object} [options] - Опции + * @param {number} [options.limit] - Количество сообщений + * @returns {Promise} + */ + async loadAround(chatId, timestamp, options = {}) { + const { limit = this.config.INITIAL_LIMIT } = options; + const requestKey = `around_date_${chatId}_${timestamp}`; + + console.log('[MessageLoaderV2] Loading around timestamp:', { + chatId, + timestamp, + date: new Date(timestamp).toISOString(), + limit + }); + + this._abortRequest(requestKey); + + try { + // API ожидает timestamp в виде "around_id" параметра + // API определяет что это timestamp если значение > 1_000_000_000 + const result = await this._fetchWithRetry( + buildUrl(API_ENDPOINTS.MESSAGES_AROUND(chatId), { + around_id: timestamp, + limit + }), + requestKey + ); + + const messages = this._extractMessages(result); + + // Добавляем в Store + this._processMessages(chatId, messages); + + // Обновляем границы для подгрузки истории + const oldestMessage = this.store.getOldestMessage(chatId); + const newestMessage = this.store.getNewestMessage(chatId); + + const loadResult = { + messages, + anchorId: result.anchor_id || messages[0]?.id || null, + anchorIndex: result.anchor_index || null, + hasMoreBefore: result.has_more_before !== false, + hasMoreAfter: result.has_more_after !== false, + oldestId: oldestMessage?.id || null, + newestId: newestMessage?.id || null + }; + + // КРИТИЧНО: Обновляем boundaries в loader для подгрузки истории + this._updateBoundaries(chatId, loadResult); + + console.log('[MessageLoaderV2] Loaded around date:', { + messagesCount: messages.length, + anchorId: loadResult.anchorId, + hasMoreBefore: loadResult.hasMoreBefore, + hasMoreAfter: loadResult.hasMoreAfter, + oldestId: loadResult.oldestId, + newestId: loadResult.newestId + }); + + return loadResult; + + } catch (error) { + if (error.name === 'AbortError') { + console.log('[MessageLoaderV2] Load around date aborted'); + return { + messages: [], + anchorId: null, + hasMoreBefore: false, + hasMoreAfter: false + }; + } + + console.error('[MessageLoaderV2] Load around date failed:', error); + throw error; + + } finally { + this._cleanupRequest(requestKey); + } + } + // ==================== WebSocket обработчики ==================== /** @@ -423,6 +533,16 @@ export class MessageLoaderV2 { return this._getBoundaries(chatId).hasMoreBefore; } + /** + * Проверяет, есть ли ещё новые сообщения для загрузки + * Используется после перехода по дате для определения нужна ли подгрузка вниз + * @param {number} chatId - ID чата + * @returns {boolean} + */ + hasMoreAfter(chatId) { + return this._getBoundaries(chatId).hasMoreAfter; + } + /** * Проверяет, идет ли сейчас какая-либо загрузка * @param {number} chatId - ID чата diff --git a/backend/static/js/managers/scrollManagerV2.js b/backend/static/js/managers/scrollManagerV2.js index fb675368..fa41ec54 100644 --- a/backend/static/js/managers/scrollManagerV2.js +++ b/backend/static/js/managers/scrollManagerV2.js @@ -72,7 +72,9 @@ export class ScrollManagerV2 { // Состояние this._historyObserver = null; + this._newerObserver = null; // ✅ Observer для новых сообщений (как в Telegram) this._isLoadingHistory = false; + this._isLoadingNewer = false; // ✅ Флаг загрузки новых this._destroyed = false; this._isInitializing = true; // ✅ Флаг инициализации @@ -85,6 +87,12 @@ export class ScrollManagerV2 { () => this._triggerLoadHistory(), LOADER_CONFIG.HISTORY_DEBOUNCE ); + + // ✅ Debounced функция для загрузки новых сообщений + this._debouncedLoadNewer = debounce( + () => this._triggerLoadNewer(), + LOADER_CONFIG.HISTORY_DEBOUNCE + ); console.log('[ScrollManagerV2] Created for chat:', this.chatId); } @@ -135,11 +143,17 @@ export class ScrollManagerV2 { this._destroyed = true; - // Отключаем observer + // Отключаем observers if (this._historyObserver) { this._historyObserver.disconnect(); this._historyObserver = null; } + + // ✅ Отключаем observer для новых сообщений + if (this._newerObserver) { + this._newerObserver.disconnect(); + this._newerObserver = null; + } // Удаляем слушатели if (this._boundScrollHandler) { @@ -258,13 +272,15 @@ export class ScrollManagerV2 { await new Promise(resolve => { requestAnimationFrame(() => { requestAnimationFrame(() => { - // КЛЮЧЕВОЙ МОМЕНТ: вычисляем разницу в высоте + // ✅ ФИЗИКА: НЕ АВТОСКРОЛЛ! Компенсация роста scrollHeight вверх + // При prepend сообщений scrollHeight растет ВВЕРХ + // Без коррекции: scrollTop фиксирован → визуальный ПРЫЖОК вверх + // С коррекцией: scrollTop += heightDiff → пользователь видит ТЕ ЖЕ сообщения const newScrollHeight = this.scrollEl.scrollHeight; const heightDifference = newScrollHeight - oldScrollHeight; - // Корректируем scrollTop на разницу высоты - // Это сохраняет визуальную позицию пользователя ТОЧНО там же - // БЕЗ КАКИХ-ЛИБО ПРЫЖКОВ! + // КРИТИЧНО: Без этой строки - бесконечная загрузка истории! + // Первое сообщение остается в viewport → observer снова его видит this.scrollEl.scrollTop = oldScrollTop + heightDifference; console.log('[ScrollManagerV2] Scroll restored using height difference:', { @@ -305,6 +321,98 @@ export class ScrollManagerV2 { } } + /** + * Загружает более новые сообщения (при прокрутке вниз после перехода по дате) + * Как в Telegram: при переходе к старой дате и прокрутке вниз подгружаем новые сообщения + * @returns {Promise} + */ + async loadMoreNewer() { + console.log('[ScrollManagerV2] ======================================'); + console.log('[ScrollManagerV2] loadMoreNewer called'); + + if (this._isLoadingNewer || this._destroyed) { + console.log('[ScrollManagerV2] ❌ Skipped: already loading newer or destroyed'); + return []; + } + + if (!this.loader.hasMoreAfter(this.chatId)) { + console.log('[ScrollManagerV2] ❌ No more newer messages available'); + return []; + } + + this._isLoadingNewer = true; + + try { + const messagesBefore = this.scrollEl.querySelectorAll('.msg[data-message-id]').length; + + console.log('[ScrollManagerV2] BEFORE loadNewer:', { + messagesBefore, + scrollTop: this.scrollEl.scrollTop, + scrollHeight: this.scrollEl.scrollHeight + }); + + // Загружаем новые сообщения через loader + const messages = await this.loader.loadNewer(this.chatId); + + console.log('[ScrollManagerV2] AFTER loader.loadNewer:', { + messagesLoaded: messages.length, + messageIds: messages.map(m => m.id) + }); + + if (messages.length === 0) { + console.log('[ScrollManagerV2] ⚠️ No new messages loaded'); + return []; + } + + // Проверяем какие сообщения реально новые + const existingIds = new Set( + Array.from(this.scrollEl.querySelectorAll('.msg[data-message-id]')) + .map(el => el.dataset.messageId) + ); + const newMessages = messages.filter(m => !existingIds.has(String(m.id))); + + if (newMessages.length === 0) { + console.log('[ScrollManagerV2] ⚠️ All messages already rendered'); + // Обновляем observer даже если сообщения уже отрендерены + // Это важно когда loadAround загрузил меньше сообщений чем есть в чате + this._updateNewerObserverTarget(); + return messages; + } + + console.log('[ScrollManagerV2] ✅ Newer messages loaded:', newMessages.length); + + // Рендерим новые сообщения в КОНЕЦ контейнера + const fragment = this.renderer.appendMessages(newMessages, this.chatId); + + if (fragment && fragment.hasChildNodes()) { + this.scrollEl.appendChild(fragment); + + // Обновляем observer на новое последнее сообщение + this._updateNewerObserverTarget(); + + const messagesAfter = this.scrollEl.querySelectorAll('.msg[data-message-id]').length; + console.log('[ScrollManagerV2] ✅ NEWER FINAL STATE:', { + messagesAfter, + totalAdded: messagesAfter - messagesBefore + }); + } else { + // Даже если fragment пустой, обновляем observer + this._updateNewerObserverTarget(); + } + + console.log('[ScrollManagerV2] ======================================'); + return newMessages; + + } catch (error) { + console.error('[ScrollManagerV2] ❌ Newer load failed:', error); + console.log('[ScrollManagerV2] ======================================'); + return []; + + } finally { + this._isLoadingNewer = false; + } + } + /** * Прокручивает к низу чата * @param {Object} [options] @@ -312,29 +420,10 @@ export class ScrollManagerV2 { * @param {boolean} [options.force=false] - Игнорировать позицию пользователя */ scrollToBottom(options = {}) { - const { instant = false, force = false } = options; - - if (!force && !this.isNearBottom()) { - return; - } - - if (instant) { - // Мгновенный скролл с visibility hiding - this.scrollEl.style.visibility = 'hidden'; - requestAnimationFrame(() => { - requestAnimationFrame(() => { - this.scrollEl.scrollTop = this.scrollEl.scrollHeight; - this.scrollEl.style.visibility = ''; - }); - }); - } else { - // Обычный скролл - БЕЗ анимации (как в старом коде) - // Используем scrollTop вместо scrollTo({ behavior: 'smooth' }) - // чтобы избежать незавершенных анимаций - requestAnimationFrame(() => { - this.scrollEl.scrollTop = this.scrollEl.scrollHeight; - }); - } + // 🚫 REMOVED: Автоскролл не нужен! + // IntersectionObserver + физика prepend = естественное поведение + // Пользователь сам управляет своей позицией скролла + console.log('[ScrollManagerV2] scrollToBottom() removed - user controls scroll'); } /** @@ -345,18 +434,10 @@ export class ScrollManagerV2 { * @param {string} [options.behavior='auto'] - Тип анимации ('auto', 'smooth', 'instant') */ scrollToMessage(messageId, options = {}) { - const messageEl = this.scrollEl.querySelector(`[data-message-id="${messageId}"]`); - - if (!messageEl) { - console.warn('[ScrollManagerV2] Message not found:', messageId); - return; - } - - messageEl.scrollIntoView({ - block: options.block || 'center', - behavior: options.behavior || 'auto', - inline: 'nearest' - }); + // 🚫 REMOVED: Автоскролл к сообщению не нужен! + // loadAround загрузит сообщения, пользователь увидит контекст + // Принудительный скролл мешает навигации + console.log('[ScrollManagerV2] scrollToMessage() removed - showing context only'); } /** @@ -443,7 +524,7 @@ export class ScrollManagerV2 { // Создаем observer this._historyObserver = new IntersectionObserver( - (entries) => this._handleIntersection(entries), + (entries) => this._handleHistoryIntersection(entries), { root: this.scrollEl, threshold: this.config.OBSERVER_THRESHOLD, @@ -453,6 +534,49 @@ export class ScrollManagerV2 { this._historyObserver.observe(firstMessage); console.log('[ScrollManagerV2] ✅ Observer attached to first message'); + + // ✅ Настраиваем observer для новых сообщений (последнее сообщение) + this._setupNewerObserver(); + } + + /** + * Настраивает IntersectionObserver для автозагрузки НОВЫХ сообщений + * Как в Telegram: при прокрутке вниз после перехода по дате + * @private + */ + _setupNewerObserver() { + console.log('[ScrollManagerV2] 🔍 _setupNewerObserver called'); + + const messages = this.scrollEl.querySelectorAll('.msg[data-message-id]'); + const lastMessage = messages[messages.length - 1]; + + if (!lastMessage) { + console.log('[ScrollManagerV2] ❌ No last message for newer observer'); + return; + } + + // Проверяем есть ли ещё новые сообщения для загрузки + if (!this.loader.hasMoreAfter(this.chatId)) { + console.log('[ScrollManagerV2] ❌ No more newer messages, skipping observer'); + return; + } + + console.log('[ScrollManagerV2] Last message found:', { + messageId: lastMessage.dataset.messageId + }); + + // Создаем observer для последнего сообщения + this._newerObserver = new IntersectionObserver( + (entries) => this._handleNewerIntersection(entries), + { + root: this.scrollEl, + threshold: this.config.OBSERVER_THRESHOLD, + rootMargin: '0px 0px 200px 0px' // Триггер при приближении к низу + } + ); + + this._newerObserver.observe(lastMessage); + console.log('[ScrollManagerV2] ✅ Newer observer attached to last message'); } /** @@ -482,11 +606,11 @@ export class ScrollManagerV2 { // ==================== Приватные методы - Handlers ==================== /** - * Обработчик IntersectionObserver + * Обработчик IntersectionObserver для ИСТОРИИ (старые сообщения) * @private */ - _handleIntersection(entries) { - console.log('[ScrollManagerV2] 👁️ IntersectionObserver triggered:', { + _handleHistoryIntersection(entries) { + console.log('[ScrollManagerV2] 👁️ History IntersectionObserver triggered:', { entriesCount: entries.length, scrollTop: this.scrollEl.scrollTop, scrollHeight: this.scrollEl.scrollHeight, @@ -495,7 +619,7 @@ export class ScrollManagerV2 { }); entries.forEach(entry => { - console.log('[ScrollManagerV2] Entry:', { + console.log('[ScrollManagerV2] History Entry:', { target: entry.target.dataset?.messageId, isIntersecting: entry.isIntersecting, intersectionRatio: entry.intersectionRatio, @@ -523,6 +647,42 @@ export class ScrollManagerV2 { }); } + /** + * Обработчик IntersectionObserver для НОВЫХ сообщений + * Как в Telegram: при прокрутке вниз после перехода к старой дате + * @private + */ + _handleNewerIntersection(entries) { + console.log('[ScrollManagerV2] 👁️ Newer IntersectionObserver triggered:', { + entriesCount: entries.length, + scrollTop: this.scrollEl.scrollTop, + isInitializing: this._isInitializing + }); + + entries.forEach(entry => { + console.log('[ScrollManagerV2] Newer Entry:', { + target: entry.target.dataset?.messageId, + isIntersecting: entry.isIntersecting, + isLoadingNewer: this._isLoadingNewer, + willLoadNewer: entry.isIntersecting && !this._isLoadingNewer && !this._isInitializing + }); + + // Игнорируем во время инициализации + if (this._isInitializing) { + console.log('[ScrollManagerV2] 🚫 Initializing, ignoring newer observer event'); + return; + } + + // Загружаем новые сообщения при видимости последнего + if (entry.isIntersecting && !this._isLoadingNewer) { + console.log('[ScrollManagerV2] 🚀 Triggering debounced newer load...'); + this._debouncedLoadNewer(); + } else if (entry.isIntersecting && this._isLoadingNewer) { + console.log('[ScrollManagerV2] ⏸️ Already loading newer, skipping'); + } + }); + } + /** * Триггер загрузки истории * @private @@ -532,6 +692,15 @@ export class ScrollManagerV2 { this.loadMoreHistory(); } + /** + * Триггер загрузки новых сообщений + * @private + */ + _triggerLoadNewer() { + console.log('[ScrollManagerV2] 🎬 _triggerLoadNewer called (from debounced)'); + this.loadMoreNewer(); + } + /** * Обработчик скролла * @private @@ -563,7 +732,7 @@ export class ScrollManagerV2 { // ==================== Приватные методы - Observer ==================== /** - * Обновляет target IntersectionObserver + * Обновляет target IntersectionObserver для истории * @private */ _updateObserverTarget() { @@ -577,6 +746,44 @@ export class ScrollManagerV2 { } } + /** + * Обновляет target IntersectionObserver для новых сообщений + * @private + */ + _updateNewerObserverTarget() { + // Отключаем старый observer + if (this._newerObserver) { + this._newerObserver.disconnect(); + } + + // Проверяем есть ли ещё новые сообщения + if (!this.loader.hasMoreAfter(this.chatId)) { + console.log('[ScrollManagerV2] No more newer messages, removing observer'); + this._newerObserver = null; + return; + } + + // Находим последнее сообщение + const messages = this.scrollEl.querySelectorAll('.msg[data-message-id]'); + const lastMessage = messages[messages.length - 1]; + + if (lastMessage) { + if (!this._newerObserver) { + // Создаем новый observer если нужно + this._newerObserver = new IntersectionObserver( + (entries) => this._handleNewerIntersection(entries), + { + root: this.scrollEl, + threshold: this.config.OBSERVER_THRESHOLD, + rootMargin: '0px 0px 200px 0px' + } + ); + } + this._newerObserver.observe(lastMessage); + console.log('[ScrollManagerV2] ✅ Newer observer updated to last message:', lastMessage.dataset.messageId); + } + } + // ==================== Приватные методы - Scroll Position ==================== /** diff --git a/backend/static/js/renderers/messageRendererV2.js b/backend/static/js/renderers/messageRendererV2.js index d97d3dd4..29cb55b2 100644 --- a/backend/static/js/renderers/messageRendererV2.js +++ b/backend/static/js/renderers/messageRendererV2.js @@ -9,6 +9,9 @@ * - Нет дублирования логики */ +import { DateDividerManager } from '../utils/dateDividers.js'; +import { DateGroupManager } from '../managers/dateGroupManager.js'; + /** * Экранирует HTML для безопасного вывода * @param {string} text - Текст для экранирования @@ -35,6 +38,7 @@ export class MessageRendererV2 { * @param {MessageStore} options.store - Экземпляр MessageStore * @param {string} options.containerId - ID контейнера для сообщений * @param {number} options.currentUserId - ID текущего пользователя + * @param {DateGroupManager} [options.dateGroupManager] - Менеджер date groups * @param {string} [options.profileUrl] - URL шаблон профиля * @param {string} [options.detailUrlTemplate] - URL шаблон детальной информации */ @@ -46,13 +50,16 @@ export class MessageRendererV2 { this.store = options.store; this.containerId = options.containerId || 'chatScroll'; this.currentUserId = options.currentUserId; + this.dateGroupManager = options.dateGroupManager || null; this.profileUrl = options.profileUrl || '/employees/profile/'; this.detailUrlTemplate = options.detailUrlTemplate || '/employees/detail/0/'; /** @type {Set} ID сообщений которые уже в DOM */ this.renderedMessages = new Set(); - console.log('[MessageRendererV2] Initialized'); + console.log('[MessageRendererV2] Initialized', { + hasDateGroupManager: !!this.dateGroupManager + }); } // ==================== Основные методы рендеринга ==================== @@ -73,10 +80,10 @@ export class MessageRendererV2 { const { clear = true } = options; - // Получаем сообщения с dividers из Store - const items = this.store.getMessagesWithDividers(chatId); + // Получаем сообщения из Store (без dividers, groupAndRender сам добавит) + const messages = this.store.getMessagesForChat(chatId); - if (items.length === 0) { + if (messages.length === 0) { if (clear) { container.innerHTML = ''; this.renderedMessages.clear(); @@ -84,18 +91,46 @@ export class MessageRendererV2 { return; } - // Очищаем ДО создания fragment + // Очищаем ДО рендеринга if (clear) { container.innerHTML = ''; this.renderedMessages.clear(); + // Очищаем DateGroupManager если есть + if (this.dateGroupManager) { + this.dateGroupManager.clear(); + } } + // Если есть DateGroupManager - используем группировку + if (this.dateGroupManager) { + console.log('[MessageRendererV2] Using DateGroupManager for full render'); + + // Используем groupAndRender для создания date groups + const groups = this.dateGroupManager.groupAndRender( + messages, + (msg) => { + this.renderedMessages.add(msg.id); + return this._createMessageElement(msg); + }, + 'append' + ); + + console.log('[MessageRendererV2] Rendered', messages.length, 'messages in', groups.length, 'date groups'); + return; + } + + // Fallback: старая логика без DateGroupManager + console.log('[MessageRendererV2] Using legacy rendering (no DateGroupManager)'); + + // Получаем сообщения с dividers из Store + const items = this.store.getMessagesWithDividers(chatId); + // Используем DocumentFragment для батчинга const fragment = document.createDocumentFragment(); items.forEach(item => { - if (item.type === 'day-divider') { - fragment.appendChild(this._createDayDivider(item.text)); + if (item.type === 'divider') { + fragment.appendChild(DateDividerManager.createDivider(item.text)); } else if (item.type === 'message') { if (!this.renderedMessages.has(item.message.id)) { const messageEl = this._createMessageElement(item.message); @@ -108,7 +143,7 @@ export class MessageRendererV2 { // ОДНА вставка в DOM container.appendChild(fragment); - console.log('[MessageRendererV2] Rendered', items.length, 'items'); + console.log('[MessageRendererV2] Rendered', items.length, 'items (legacy mode)'); } /** @@ -134,13 +169,41 @@ export class MessageRendererV2 { console.log('[MessageRendererV2] Prepending', messages.length, 'messages'); const fragment = document.createDocumentFragment(); + + // Если есть DateGroupManager - используем группировку + if (this.dateGroupManager) { + console.log('[MessageRendererV2] Using DateGroupManager'); + + // Создаем группы с помощью DateGroupManager + const groups = this.dateGroupManager.groupAndRender( + messages, + (msg) => { + if (!this.renderedMessages.has(msg.id)) { + this.renderedMessages.add(msg.id); + return this._createMessageElement(msg); + } + return null; + }, + 'prepend' // Важно: указываем что это prepend + ); + + // Добавляем все группы в fragment + groups.forEach(group => fragment.appendChild(group)); + + console.log('[MessageRendererV2] Created', groups.length, 'date groups'); + console.log('[MessageRendererV2] ======================================'); + return fragment; + } + + // Fallback: старая логика без групп + console.log('[MessageRendererV2] Using legacy dividers (no DateGroupManager)'); const items = []; // Группируем сообщения по дням (от старых к новым) let lastDay = null; messages.forEach(msg => { const msgDate = new Date(msg.created_ts); - const msgDay = this._formatDay(msgDate); + const msgDay = DateDividerManager.formatDay(msgDate); // Добавляем divider если день изменился if (msgDay !== lastDay) { @@ -162,7 +225,7 @@ export class MessageRendererV2 { let added = 0; items.forEach(item => { if (item.type === 'day-divider') { - fragment.appendChild(this._createDayDivider(item.text)); + fragment.appendChild(DateDividerManager.createDivider(item.text)); added++; } else if (item.type === 'message') { if (!this.renderedMessages.has(item.message.id)) { @@ -187,6 +250,115 @@ export class MessageRendererV2 { return fragment; } + /** + * Добавляет новые сообщения в КОНЕЦ (для загрузки при прокрутке вниз после перехода по дате) + * Как в Telegram: при прокрутке вниз после перехода к старой дате + * @param {Array} messages - Массив сообщений (от старых к новым) + * @param {number} chatId - ID чата + * @returns {DocumentFragment} Fragment с элементами для append + */ + appendMessages(messages, chatId) { + console.log('[MessageRendererV2] ======================================'); + console.log('[MessageRendererV2] appendMessages called:', { + messagesCount: messages?.length || 0, + chatId, + messageIds: messages?.map(m => m.id) || [] + }); + + if (!messages || messages.length === 0) { + console.log('[MessageRendererV2] ❌ No messages to append'); + console.log('[MessageRendererV2] ======================================'); + return document.createDocumentFragment(); + } + + console.log('[MessageRendererV2] Appending', messages.length, 'messages'); + + const fragment = document.createDocumentFragment(); + + // Если есть DateGroupManager - используем группировку + if (this.dateGroupManager) { + console.log('[MessageRendererV2] Using DateGroupManager for append'); + + // Создаем группы с помощью DateGroupManager + const groups = this.dateGroupManager.groupAndRender( + messages, + (msg) => { + if (!this.renderedMessages.has(msg.id)) { + this.renderedMessages.add(msg.id); + return this._createMessageElement(msg); + } + return null; + }, + 'append' // Указываем что это append + ); + + // Добавляем все группы в fragment + groups.forEach(group => fragment.appendChild(group)); + + console.log('[MessageRendererV2] Created', groups.length, 'date groups for append'); + console.log('[MessageRendererV2] ======================================'); + return fragment; + } + + // Fallback: старая логика без групп + console.log('[MessageRendererV2] Using legacy dividers for append (no DateGroupManager)'); + const items = []; + + // Получаем последний день из контейнера для корректных divider'ов + const container = document.getElementById(this.containerId); + const lastDivider = container?.querySelector('.day-divider:last-of-type'); + let lastDay = lastDivider ? lastDivider.textContent.trim() : null; + + // Группируем сообщения по дням (от старых к новым) + messages.forEach(msg => { + const msgDate = new Date(msg.created_ts); + const msgDay = DateDividerManager.formatDay(msgDate); + + // Добавляем divider если день изменился + if (msgDay !== lastDay) { + items.push({ type: 'day-divider', text: msgDay }); + lastDay = msgDay; + } + + items.push({ type: 'message', message: msg }); + }); + + console.log('[MessageRendererV2] Items to append:', { + totalItems: items.length, + dividers: items.filter(i => i.type === 'day-divider').length, + messages: items.filter(i => i.type === 'message').length + }); + + // Создаем элементы + let skipped = 0; + let added = 0; + items.forEach(item => { + if (item.type === 'day-divider') { + fragment.appendChild(DateDividerManager.createDivider(item.text)); + added++; + } else if (item.type === 'message') { + if (!this.renderedMessages.has(item.message.id)) { + const messageEl = this._createMessageElement(item.message); + fragment.appendChild(messageEl); + this.renderedMessages.add(item.message.id); + added++; + } else { + skipped++; + console.log('[MessageRendererV2] ⚠️ Skipped duplicate in append:', item.message.id); + } + } + }); + + console.log('[MessageRendererV2] Append fragment created:', { + added, + skipped, + fragmentChildren: fragment.childElementCount + }); + console.log('[MessageRendererV2] ======================================'); + + return fragment; + } + /** * Удаляет дубликаты day-divider после prepend * @param {HTMLElement} container - Контейнер с сообщениями @@ -232,6 +404,28 @@ export class MessageRendererV2 { console.log('[MessageRendererV2] Appending message:', message.id); + // Если есть DateGroupManager - используем группировку + if (this.dateGroupManager) { + const messageDate = DateDividerManager.getMessageDate(message); + const dateText = DateDividerManager.formatDay(messageDate); + + // Создаем элемент сообщения + const messageEl = this._createMessageElement(message); + + // Добавляем в соответствующую группу + this.dateGroupManager.addMessageToGroup( + messageEl, + dateText, + messageDate.getTime(), + 'append' + ); + + this.renderedMessages.add(message.id); + console.log('[MessageRendererV2] Message added to date group:', dateText); + return; + } + + // Fallback: старая логика без групп // Проверяем нужен ли day-divider // Сравниваем с последним сообщением, а не с последним divider const lastMessage = container.querySelector('.msg:last-of-type'); @@ -240,20 +434,20 @@ export class MessageRendererV2 { const lastMsgData = this.store.getMessage(lastMsgId); if (lastMsgData) { const lastMsgDate = new Date(lastMsgData.created_ts); - const lastMsgDay = this._formatDay(lastMsgDate); + const lastMsgDay = DateDividerManager.formatDay(lastMsgDate); const msgDate = new Date(message.created_ts); - const msgDay = this._formatDay(msgDate); + const msgDay = DateDividerManager.formatDay(msgDate); // Добавляем divider только если день изменился if (msgDay !== lastMsgDay) { - container.appendChild(this._createDayDivider(msgDay)); + container.appendChild(DateDividerManager.createDivider(msgDay)); } } } else { // Первое сообщение - добавляем divider const msgDate = new Date(message.created_ts); - const msgDay = this._formatDay(msgDate); - container.appendChild(this._createDayDivider(msgDay)); + const msgDay = DateDividerManager.formatDay(msgDate); + container.appendChild(DateDividerManager.createDivider(msgDay)); } // Создаем и добавляем сообщение @@ -328,17 +522,6 @@ export class MessageRendererV2 { // ==================== Создание элементов ==================== - /** - * Создает элемент day-divider - * @private - */ - _createDayDivider(text) { - const dividerEl = document.createElement('div'); - dividerEl.className = 'day-divider text-center small text-muted my-3'; - dividerEl.innerHTML = `${escapeHtml(text)}`; - return dividerEl; - } - /** * Создает элемент сообщения * @private @@ -556,25 +739,6 @@ export class MessageRendererV2 { // ==================== Утилиты ==================== - /** - * Форматирует дату в текст дня - * @private - */ - _formatDay(date) { - const today = new Date(); - const yesterday = new Date(today); - yesterday.setDate(yesterday.getDate() - 1); - - const isToday = date.toDateString() === today.toDateString(); - const isYesterday = date.toDateString() === yesterday.toDateString(); - - if (isToday) return 'Сегодня'; - if (isYesterday) return 'Вчера'; - - const options = { day: 'numeric', month: 'long', year: 'numeric' }; - return date.toLocaleDateString('ru-RU', options); - } - /** * Форматирует время * @private @@ -594,6 +758,12 @@ export class MessageRendererV2 { if (container) { container.innerHTML = ''; this.renderedMessages.clear(); + + // Очищаем DateGroupManager если есть + if (this.dateGroupManager) { + this.dateGroupManager.clear(); + } + console.log('[MessageRendererV2] Container cleared'); } } diff --git a/backend/static/js/stores/messageStoreV2.js b/backend/static/js/stores/messageStoreV2.js index 8cb2200e..4d86ae3b 100644 --- a/backend/static/js/stores/messageStoreV2.js +++ b/backend/static/js/stores/messageStoreV2.js @@ -11,6 +11,7 @@ */ import { STORE_EVENTS } from '../config/chatConfig.js'; +import { DateDividerManager } from '../utils/dateDividers.js'; /** * @typedef {Object} Message @@ -324,7 +325,7 @@ export class MessageStoreV2 { let lastDay = null; for (const message of messages) { - const day = this._formatDay(new Date(message.created_ts)); + const day = DateDividerManager.formatDay(new Date(message.created_ts)); if (day !== lastDay) { result.push({ type: 'divider', text: day }); @@ -494,25 +495,6 @@ export class MessageStoreV2 { } } - /** - * Форматирует дату в текст дня - * @private - */ - _formatDay(date) { - const today = new Date(); - const yesterday = new Date(today); - yesterday.setDate(yesterday.getDate() - 1); - - if (date.toDateString() === today.toDateString()) return 'Сегодня'; - if (date.toDateString() === yesterday.toDateString()) return 'Вчера'; - - return date.toLocaleDateString('ru-RU', { - day: 'numeric', - month: 'long', - year: 'numeric' - }); - } - /** * Уведомляет подписчиков * @private