diff --git a/backend/static/js/loaders/messageLoaderV2.js b/backend/static/js/loaders/messageLoaderV2.js index bd6cc04e..1ca7d6bf 100644 --- a/backend/static/js/loaders/messageLoaderV2.js +++ b/backend/static/js/loaders/messageLoaderV2.js @@ -184,7 +184,21 @@ export class MessageLoaderV2 { const messages = this._extractMessages(result); if (messages.length > 0) { - this._processMessages(chatId, messages); + // Определяем какие сообщения НОВЫЕ (не были в Store) + const existingIds = new Set( + this.store.getMessagesForChat(chatId).map(m => m.id) + ); + const newMessages = messages.filter(m => !existingIds.has(m.id)); + + console.log('[MessageLoaderV2] Filtering messages:', { + total: messages.length, + existing: messages.length - newMessages.length, + new: newMessages.length + }); + + // Добавляем в Store БЕЗ событий (silent mode) + // Отрисовка будет через prependMessages + this._processMessages(chatId, messages, { silent: true }); // Обновляем границы const oldestMessage = this.store.getOldestMessage(chatId); @@ -192,12 +206,15 @@ export class MessageLoaderV2 { hasMoreBefore: result.has_more !== false, oldestId: oldestMessage?.id || null }); + + // Возвращаем только НОВЫЕ сообщения + return newMessages; } else { this._updateBoundaries(chatId, { hasMoreBefore: false }); } console.log('[MessageLoaderV2] History loaded:', messages.length, 'messages'); - return messages; + return []; } catch (error) { if (error.name === 'AbortError') { @@ -447,12 +464,22 @@ export class MessageLoaderV2 { requestKey ); + const messages = this._extractMessages(result); + + // ВАЖНО: Сначала добавляем в Store, затем берем границы из Store + this._processMessages(chatId, messages); + + const oldestMessage = this.store.getOldestMessage(chatId); + const newestMessage = this.store.getNewestMessage(chatId); + return { - messages: this._extractMessages(result), + messages, anchorId: result.anchor_id || aroundId, anchorIndex: result.anchor_index || null, hasMoreBefore: result.has_more_before !== false, - hasMoreAfter: result.has_more_after !== false + hasMoreAfter: result.has_more_after !== false, + oldestId: oldestMessage?.id || null, + newestId: newestMessage?.id || null }; } @@ -468,11 +495,22 @@ export class MessageLoaderV2 { requestKey ); + const messages = this._extractMessages(result); + + // ВАЖНО: Сначала добавляем в Store, затем берем границы из Store + // Store гарантирует правильную сортировку по timestamp + this._processMessages(chatId, messages); + + const oldestMessage = this.store.getOldestMessage(chatId); + const newestMessage = this.store.getNewestMessage(chatId); + return { - messages: this._extractMessages(result), + messages, anchorId: null, hasMoreBefore: result.has_more !== false, - hasMoreAfter: false + hasMoreAfter: false, + oldestId: oldestMessage?.id || null, + newestId: newestMessage?.id || null }; } @@ -575,8 +613,12 @@ export class MessageLoaderV2 { /** * Обрабатывает и сохраняет сообщения в Store * @private + * @param {number} chatId + * @param {Array} messages + * @param {Object} [options] + * @param {boolean} [options.silent=false] - Не emit'ить события */ - _processMessages(chatId, messages) { + _processMessages(chatId, messages, options = {}) { // Добавляем chat_id если отсутствует messages.forEach(msg => { if (!msg.chat_id) { @@ -584,7 +626,7 @@ export class MessageLoaderV2 { } }); - this.store.addMessages(messages); + this.store.addMessages(messages, options); } /** diff --git a/backend/static/js/managers/scrollManagerV2.js b/backend/static/js/managers/scrollManagerV2.js index 8d0a60bb..b4b86744 100644 --- a/backend/static/js/managers/scrollManagerV2.js +++ b/backend/static/js/managers/scrollManagerV2.js @@ -135,38 +135,147 @@ export class ScrollManagerV2 { * @returns {Promise} */ async loadMoreHistory() { + console.log('[ScrollManagerV2] ======================================'); + console.log('[ScrollManagerV2] loadMoreHistory called'); + if (this._isLoadingHistory || this._destroyed) { + console.log('[ScrollManagerV2] ❌ Skipped: already loading or destroyed'); return []; } if (!this.loader.hasMoreHistory(this.chatId)) { + console.log('[ScrollManagerV2] ❌ No more history available'); return []; } this._isLoadingHistory = true; try { - // Сохраняем позицию - const savedPosition = this._saveScrollPosition(); + // Отключаем smooth scroll для мгновенных изменений + const originalScrollBehavior = this.scrollEl.style.scrollBehavior; + this.scrollEl.style.scrollBehavior = 'auto'; + + // Сохраняем высоты ДО загрузки + const oldScrollHeight = this.scrollEl.scrollHeight; + const oldScrollTop = this.scrollEl.scrollTop; + const messagesBefore = this.scrollEl.querySelectorAll('.msg[data-message-id]').length; + + console.log('[ScrollManagerV2] BEFORE load:', { + oldScrollHeight, + oldScrollTop, + messagesBefore + }); // Загружаем историю const messages = await this.loader.loadHistory(this.chatId); - if (messages.length > 0) { - // Ре-рендерим - await this.renderer.render(this.chatId); - - // Восстанавливаем позицию - this._restoreScrollPosition(savedPosition); - - // Обновляем observer - this._updateObserverTarget(); + console.log('[ScrollManagerV2] AFTER loader.loadHistory:', { + messagesLoaded: messages.length, + messageIds: messages.map(m => m.id) + }); + + // Проверяем есть ли еще история ПОСЛЕ загрузки + const stillHasMore = this.loader.hasMoreHistory(this.chatId); + + if (messages.length === 0 || !stillHasMore) { + console.log('[ScrollManagerV2] ⚠️ Stopping: no messages or no more history', { + messagesLength: messages.length, + stillHasMore + }); + this.scrollEl.style.scrollBehavior = originalScrollBehavior; + return []; } + console.log('[ScrollManagerV2] ✅ History loaded:', messages.length, 'messages'); + + // КЛЮЧЕВОЙ МОМЕНТ: НЕ делаем полный render! + // Создаем fragment с новыми сообщениями + const fragment = this.renderer.prependMessages(messages, this.chatId); + + console.log('[ScrollManagerV2] Fragment created:', { + hasChildNodes: fragment.hasChildNodes(), + childCount: fragment.childElementCount + }); + + // Если fragment пустой (все сообщения уже отрендерены), выходим + if (!fragment.hasChildNodes()) { + console.log('[ScrollManagerV2] ⚠️ All messages already rendered'); + this.scrollEl.style.scrollBehavior = originalScrollBehavior; + return messages; + } + + // ВАЖНО: Проверяем и удаляем потенциальные дубликаты divider + // ПЕРЕД prepend, чтобы избежать визуальных скачков + const firstFragmentChild = fragment.firstElementChild; + if (firstFragmentChild && firstFragmentChild.classList.contains('day-divider')) { + const firstContainerChild = this.scrollEl.firstElementChild; + if (firstContainerChild && firstContainerChild.classList.contains('day-divider')) { + const fragmentDividerText = firstFragmentChild.textContent.trim(); + const containerDividerText = firstContainerChild.textContent.trim(); + + // Если dividers одинаковые, удаляем из fragment + if (fragmentDividerText === containerDividerText) { + fragment.removeChild(firstFragmentChild); + console.log('[ScrollManagerV2] Removed duplicate divider:', fragmentDividerText); + } + } + } + + // Prepend fragment в НАЧАЛО контейнера (как в Telegram) + this.scrollEl.prepend(fragment); + + const messagesAfterPrepend = this.scrollEl.querySelectorAll('.msg[data-message-id]').length; + console.log('[ScrollManagerV2] AFTER prepend:', { + messagesAfterPrepend, + added: messagesAfterPrepend - messagesBefore + }); + + // КЛЮЧЕВОЕ ОТЛИЧИЕ Telegram: ДВОЙНОЙ RAF для стабильности! + // Первый RAF - браузер обновляет layout + // Второй RAF - браузер гарантированно применил все изменения + await new Promise(resolve => { + requestAnimationFrame(() => { + requestAnimationFrame(() => { + // КЛЮЧЕВОЙ МОМЕНТ: вычисляем разницу в высоте + const newScrollHeight = this.scrollEl.scrollHeight; + const heightDifference = newScrollHeight - oldScrollHeight; + + // Корректируем scrollTop на разницу высоты + // Это сохраняет визуальную позицию пользователя ТОЧНО там же + // БЕЗ КАКИХ-ЛИБО ПРЫЖКОВ! + this.scrollEl.scrollTop = oldScrollTop + heightDifference; + + console.log('[ScrollManagerV2] Scroll restored using height difference:', { + oldScrollHeight, + newScrollHeight, + heightDifference, + oldScrollTop, + newScrollTop: this.scrollEl.scrollTop + }); + + // Обновляем observer на новое первое сообщение + this._updateObserverTarget(); + + // Восстанавливаем scroll-behavior + this.scrollEl.style.scrollBehavior = originalScrollBehavior; + + const finalMessages = this.scrollEl.querySelectorAll('.msg[data-message-id]').length; + console.log('[ScrollManagerV2] ✅ FINAL STATE:', { + finalMessages, + totalAdded: finalMessages - messagesBefore + }); + console.log('[ScrollManagerV2] ======================================'); + + resolve(); + }); + }); + }); + return messages; } catch (error) { - console.error('[ScrollManagerV2] History load failed:', error); + console.error('[ScrollManagerV2] ❌ History load failed:', error); + console.log('[ScrollManagerV2] ======================================'); return []; } finally { diff --git a/backend/static/js/renderers/messageRendererV2.js b/backend/static/js/renderers/messageRendererV2.js index 6d9dd4f8..d97d3dd4 100644 --- a/backend/static/js/renderers/messageRendererV2.js +++ b/backend/static/js/renderers/messageRendererV2.js @@ -118,8 +118,16 @@ export class MessageRendererV2 { * @returns {DocumentFragment} Fragment с элементами для prepend */ prependMessages(messages, chatId) { + console.log('[MessageRendererV2] ======================================'); + console.log('[MessageRendererV2] prependMessages called:', { + messagesCount: messages?.length || 0, + chatId, + messageIds: messages?.map(m => m.id) || [] + }); + if (!messages || messages.length === 0) { - console.log('[MessageRendererV2] No messages to prepend'); + console.log('[MessageRendererV2] ❌ No messages to prepend'); + console.log('[MessageRendererV2] ======================================'); return document.createDocumentFragment(); } @@ -143,19 +151,39 @@ export class MessageRendererV2 { items.push({ type: 'message', message: msg }); }); + console.log('[MessageRendererV2] Items to render:', { + 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(this._createDayDivider(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:', item.message.id); } } }); + console.log('[MessageRendererV2] Fragment created:', { + added, + skipped, + fragmentChildren: fragment.childElementCount + }); + console.log('[MessageRendererV2] ======================================'); + return fragment; } diff --git a/backend/static/js/stores/messageStoreV2.js b/backend/static/js/stores/messageStoreV2.js index 6c23d50f..8cb2200e 100644 --- a/backend/static/js/stores/messageStoreV2.js +++ b/backend/static/js/stores/messageStoreV2.js @@ -65,6 +65,7 @@ export class MessageStoreV2 { // Batch mode this._batchMode = false; + this._silentMode = false; this._pendingNotifications = []; console.log('[MessageStoreV2] Initialized'); @@ -111,15 +112,21 @@ export class MessageStoreV2 { /** * Добавляет множество сообщений (batch) * @param {Array} messages - Сообщения + * @param {Object} [options] - Опции + * @param {boolean} [options.silent=false] - Не emit'ить события * @returns {number} Количество добавленных */ - addMessages(messages) { + addMessages(messages, options = {}) { if (!Array.isArray(messages) || messages.length === 0) { return 0; } + const { silent = false } = options; + // Включаем batch mode this._batchMode = true; + const oldSilent = this._silentMode; + this._silentMode = silent; let addedCount = 0; try { @@ -131,11 +138,17 @@ export class MessageStoreV2 { } finally { // Выключаем batch mode и отправляем все уведомления this._batchMode = false; - this._flushNotifications(); + this._silentMode = oldSilent; + + if (!silent) { + this._flushNotifications(); + } } - // Отправляем групповое уведомление - this._notify(STORE_EVENTS.MESSAGES_LOADED, { count: addedCount }); + // Отправляем групповое уведомление только если не silent + if (!silent) { + this._notify(STORE_EVENTS.MESSAGES_LOADED, { count: addedCount }); + } return addedCount; }