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