refactor: Remove all autoscroll except prepend physics correction

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
This commit is contained in:
Игорь Надеин 2026-01-13 14:35:49 +03:00
parent 3e97ca2ac9
commit d3a876f1cb
5 changed files with 846 additions and 118 deletions

View File

@ -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 = `
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">
<i class="bi-calendar3 me-2"></i>Перейти к дате
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<label for="chatDateInput" class="form-label">Выберите дату</label>
<input type="date" class="form-control" id="chatDateInput" required>
<div class="form-text">
Чат загрузит сообщения вокруг выбранной даты
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">
Отмена
</button>
<button type="button" class="btn btn-primary" id="confirmDateJump">
<i class="bi-arrow-right-circle me-1"></i>Перейти
</button>
</div>
</div>
</div>
`;
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

View File

@ -239,7 +239,7 @@ export class MessageLoaderV2 {
/**
* Загружает новые сообщения (после текущего newest)
* Используется для синхронизации после reconnect
* Используется для прокрутки вниз после перехода по дате (как в Telegram)
* @param {number} chatId - ID чата
* @returns {Promise<Array<Message>>}
*/
@ -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<LoadResult>}
*/
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 чата

View File

@ -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<Array>}
*/
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 ====================
/**

View File

@ -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<number|string>} 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<Object>} 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 = `<span class="px-3 py-1 rounded-pill bg-light">${escapeHtml(text)}</span>`;
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');
}
}

View File

@ -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