Corp/backend/static/js/controllers/chatControllerV2.js
Игорь Надеин d3a876f1cb 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
2026-01-13 14:35:49 +03:00

904 lines
35 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* @fileoverview ChatController V2 - рефакторированный координатор чата
* @module controllers/chatControllerV2
*
* РЕФАКТОРИНГ:
* - Чистая архитектура с разделением ответственности
* - Минимум логирования (только важные события)
* - Улучшенная обработка ошибок
* - Полная типизация JSDoc
* - Поддержка отмены операций
*/
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';
/**
* @typedef {Object} ChatControllerOptions
* @property {number} chatId - ID чата
* @property {HTMLElement} scrollElement - Контейнер для скролла
* @property {number} currentUserId - ID текущего пользователя
* @property {string} [containerId='chatScroll'] - ID контейнера сообщений
* @property {Object} [wsConnection] - WebSocket соединение
* @property {string} [profileUrl] - URL профиля пользователя
* @property {string} [detailUrlTemplate] - Шаблон URL детальной информации
* @property {number|null} [lastReadMessageId] - ID последнего прочитанного сообщения
*/
/**
* ChatController V2 - главный координатор чата
* Единая точка входа для работы с чатом.
*/
export class ChatControllerV2 {
/**
* @param {ChatControllerOptions} options
*/
constructor(options) {
this._validateOptions(options);
// Основные параметры
this.chatId = options.chatId;
this.currentUserId = options.currentUserId;
this.scrollElement = options.scrollElement;
this.containerId = options.containerId || 'chatScroll';
this.lastReadMessageId = options.lastReadMessageId || null;
this.lastReadTimestamp = options.lastReadTimestamp || null;
// Создаем компоненты
this.store = new MessageStoreV2({ currentUserId: this.currentUserId });
// Date Group Manager для группировки по датам
this.dateGroupManager = new DateGroupManager({
container: this.scrollElement,
enableSticky: true,
interactive: true // Кликабельные даты
});
// Sticky Date Observer для IntersectionObserver
this.stickyDateObserver = new StickyDateObserver({
container: this.scrollElement,
onSticky: (isSticky, element, dateText) => {
console.log('[ChatControllerV2] Date sticky state:', { dateText, isSticky });
}
});
this.loader = new MessageLoaderV2({
store: this.store,
wsConnection: options.wsConnection,
currentUserId: this.currentUserId
});
this.renderer = new MessageRendererV2({
store: this.store,
containerId: this.containerId,
currentUserId: this.currentUserId,
dateGroupManager: this.dateGroupManager, // Передаем в renderer
profileUrl: options.profileUrl || '/employees/profile/',
detailUrlTemplate: options.detailUrlTemplate || '/employees/detail/0/'
});
this.scrollManager = new ScrollManagerV2({
scrollElement: this.scrollElement,
loader: this.loader,
renderer: this.renderer,
store: this.store,
chatId: this.chatId
});
// Состояние
this._initialized = false;
this._initializing = false;
this._destroyed = false;
// Индикатор новых сообщений (умный автоскролл)
this._newMessagesCount = 0;
this._newMessagesBtn = null;
this._scrollWatcherCleanup = null;
// Слушатели для cleanup
this._eventListeners = [];
// Подписки
this._setupStoreSubscription();
if (options.wsConnection) {
this._setupWebSocketListeners();
}
console.log('[ChatControllerV2] Created for chat:', this.chatId);
}
// ==================== Публичное API - Жизненный цикл ====================
/**
* Инициализирует чат: загружает сообщения, рендерит, настраивает скролл
* @returns {Promise<void>}
*/
async init() {
if (this._initialized) {
console.warn('[ChatControllerV2] Already initialized');
return;
}
if (this._initializing) {
console.warn('[ChatControllerV2] Initialization in progress');
return;
}
this._initializing = true;
try {
// 1. Загружаем сообщения
// Используем timestamp как fallback если нет message ID (API поддерживает оба)
const anchorValue = this.lastReadMessageId || this.lastReadTimestamp;
const loadResult = await this.loader.loadInitial(this.chatId, {
aroundMessageId: anchorValue
});
// 2. Скрываем контейнер для плавного рендера
this.scrollElement.style.visibility = 'hidden';
try {
// 3. Рендерим сообщения
await this.renderer.render(this.chatId);
// 4. СНАЧАЛА показываем контейнер (важно для scrollTop!)
this.scrollElement.style.visibility = '';
// Ждем один frame чтобы браузер применил visibility
await new Promise(resolve => requestAnimationFrame(resolve));
// 5. Скроллим к нужной позиции
if (loadResult.anchorId) {
// Есть последнее прочитанное - скроллим к нему
await this._scrollToMessage(loadResult.anchorId);
} else {
// Нет anchor - скроллим в самый низ (как в Telegram)
console.log('[ChatControllerV2] Scrolling to bottom (no anchor)...', {
scrollHeightBefore: this.scrollElement.scrollHeight,
scrollTopBefore: this.scrollElement.scrollTop,
clientHeight: this.scrollElement.clientHeight
});
// Используем ПРЯМОЕ присвоение scrollTop (как в legacy коде)
// scrollTo() НЕ работает на первичной загрузке
await new Promise(resolve => {
requestAnimationFrame(() => {
requestAnimationFrame(() => {
requestAnimationFrame(() => {
// 🚫 REMOVED: Автоскролл при открытии чата
// Пользователь сам решает куда скроллить
// loadAround уже показывает нужный контекст
console.log('[ChatControllerV2] Initial autoscroll removed - user position preserved');
// Проверяем через setTimeout
setTimeout(() => {
console.log('[ChatControllerV2] After scrollTop:', {
scrollHeight: this.scrollElement.scrollHeight,
scrollTop: this.scrollElement.scrollTop,
success: this.scrollElement.scrollTop > 0
});
resolve();
}, 10);
});
});
});
});
}
// 6. Инициализируем ScrollManager
await this.scrollManager.init();
// 7. Инициализируем StickyDateObserver
this.stickyDateObserver.init();
// 8. Настраиваем обработчик кликов на даты
this._setupDateClickHandler();
// 9. Инициализируем watcher для умного автоскролла
this._initScrollWatcher();
} catch (error) {
this.scrollElement.style.visibility = '';
throw error;
}
this._initialized = true;
this._initializing = false;
// Dispatch события
this._emit(CHAT_EVENTS.INITIALIZED, { chatId: this.chatId });
console.log('[ChatControllerV2] Initialized successfully');
} catch (error) {
this._initializing = false;
console.error('[ChatControllerV2] Initialization failed:', error);
this._emit(CHAT_EVENTS.ERROR, { error, phase: 'init' });
throw error;
}
}
/**
* Уничтожает контроллер и освобождает ресурсы
*/
destroy() {
if (this._destroyed) return;
this._destroyed = true;
// Отменяем активные запросы
this.loader.cancelAll(this.chatId);
// Уничтожаем ScrollManager
this.scrollManager?.destroy();
// Уничтожаем StickyDateObserver
this.stickyDateObserver?.destroy();
// Очищаем DateGroupManager
this.dateGroupManager?.clear();
// Очищаем Store
this.store?.clearChat(this.chatId);
// Очищаем Renderer
this.renderer?.clear();
// Удаляем scroll watcher
if (this._scrollWatcherCleanup) {
this._scrollWatcherCleanup();
this._scrollWatcherCleanup = null;
}
// Удаляем слушатели событий
this._eventListeners.forEach(({ event, handler }) => {
window.removeEventListener(event, handler);
});
this._eventListeners = [];
console.log('[ChatControllerV2] Destroyed');
}
// ==================== Публичное API - Сообщения ====================
/**
* Отправляет сообщение
* @param {string} content - Текст сообщения
* @param {Object} [options] - Дополнительные опции
* @returns {string|null} Временный ID или null при ошибке
*/
sendMessage(content, options = {}) {
if (!content?.trim()) {
console.warn('[ChatControllerV2] Cannot send empty message');
return null;
}
const tempId = this.loader.sendOptimistic(this.chatId, content.trim(), options);
return tempId;
}
/**
* Получает сообщение по ID
* @param {number|string} messageId
* @returns {Object|null}
*/
getMessage(messageId) {
return this.store.getMessage(messageId);
}
/**
* Получает все сообщения чата
* @param {Object} [options]
* @returns {Array}
*/
getMessages(options = {}) {
return this.store.getMessagesForChat(this.chatId, options);
}
// ==================== Публичное API - Скролл ====================
/**
* Загружает больше истории
* @returns {Promise<Array>}
*/
async loadMoreHistory() {
return this.scrollManager.loadMoreHistory();
}
/**
* Прокручивает к последнему сообщению
* @param {Object} [options]
*/
scrollToBottom(options = {}) {
this.scrollManager.scrollToBottom(options);
}
/**
* Прокручивает к конкретному сообщению
* @param {number|string} messageId
* @param {Object} [options]
*/
scrollToMessage(messageId, options = {}) {
this.scrollManager.scrollToMessage(messageId, options);
}
/**
* Проверяет находится ли пользователь внизу чата
* @returns {boolean}
*/
isNearBottom() {
return this.scrollManager.isNearBottom();
}
// ==================== Публичное API - Состояние ====================
/**
* Получает статус контроллера
* @returns {Object}
*/
getStatus() {
return {
chatId: this.chatId,
initialized: this._initialized,
messageCount: this.store.getMessageCount(this.chatId),
isLoading: this.loader.isLoading(this.chatId),
hasMoreHistory: this.loader.hasMoreHistory(this.chatId),
scroll: this.scrollManager.getStatus(),
store: this.store.getStats(),
renderer: this.renderer.getStats()
};
}
/**
* Проверяет инициализирован ли контроллер
* @returns {boolean}
*/
get isInitialized() {
return this._initialized;
}
// ==================== Приватные методы - Подписки ====================
/**
* Настраивает подписку на Store
* @private
*/
_setupStoreSubscription() {
this.store.subscribe((event, data) => {
// Игнорируем события во время инициализации
if (this._initializing) return;
this._handleStoreEvent(event, data);
});
}
/**
* Обрабатывает события Store
* @private
*/
_handleStoreEvent(event, data) {
switch (event) {
case 'message_added':
this._onMessageAdded(data);
break;
case 'message_updated':
this._onMessageUpdated(data);
break;
case 'message_removed':
this._onMessageRemoved(data);
break;
}
}
/**
* Обработчик добавления сообщения
* @private
*/
_onMessageAdded(data) {
const { message, optimistic } = data;
if (message.chat_id !== this.chatId) return;
// Рендерим новое сообщение
this.renderer.appendMessage(message, this.chatId);
// Обновляем StickyDateObserver (могла появиться новая date group)
if (this.stickyDateObserver?.isInitialized()) {
this.stickyDateObserver.refresh();
}
// УМНЫЙ АВТОСКРОЛЛ:
// 1. Своё сообщение - всегда скроллим
// 2. Чужое сообщение + внизу чата - скроллим (активно читаем)
// 3. Чужое сообщение + читаем историю - показываем индикатор
const isMyMessage = message.author_id === this.currentUserId;
const isAtBottom = this.scrollManager.isNearBottom();
const shouldScroll = isMyMessage || isAtBottom;
if (shouldScroll) {
// Для своих сообщений - мгновенный скролл (AUTOSCROLL_CONFIG.SMOOTH_SCROLL_FOR_OWN)
// Для чужих - проверка уже прошла выше (isAtBottom)
this.scrollManager.scrollToBottom({
instant: isMyMessage && !AUTOSCROLL_CONFIG.SMOOTH_SCROLL_FOR_OWN,
force: isMyMessage
});
// Скрываем индикатор если он показан
this._hideNewMessagesIndicator();
} else if (!shouldScroll && !optimistic) {
// Показываем индикатор только для НЕ-оптимистичных чужих сообщений
this._showNewMessagesIndicator();
}
this._emit(CHAT_EVENTS.MESSAGE_ADDED, { messageId: message.id, chatId: this.chatId });
}
/**
* Обработчик обновления сообщения
* @private
*/
_onMessageUpdated(data) {
const { messageId, updates } = data;
this.renderer.updateMessage(messageId, updates);
this._emit(CHAT_EVENTS.MESSAGE_UPDATED, { messageId, updates, chatId: this.chatId });
}
/**
* Обработчик удаления сообщения
* @private
*/
_onMessageRemoved(data) {
const { messageId } = data;
this.renderer.removeMessage(messageId);
this._emit(CHAT_EVENTS.MESSAGE_REMOVED, { messageId, chatId: this.chatId });
}
/**
* Настраивает слушатели WebSocket
* @private
*/
_setupWebSocketListeners() {
const handlers = [
[WS_EVENTS.NEW_MESSAGE, (e) => this._handleWsNewMessage(e.detail)],
[WS_EVENTS.MESSAGE_EDITED, (e) => this._handleWsMessageEdited(e.detail)],
[WS_EVENTS.MESSAGE_REMOVED, (e) => this._handleWsMessageRemoved(e.detail)],
[WS_EVENTS.REACTION_ADDED, (e) => this._handleWsReaction(e.detail)],
[WS_EVENTS.REACTION_REMOVED, (e) => this._handleWsReaction(e.detail)]
];
handlers.forEach(([event, handler]) => {
window.addEventListener(event, handler);
this._eventListeners.push({ event, handler });
});
}
/**
* @private
*/
_handleWsNewMessage(detail) {
const { message, chatId } = detail;
if (chatId === this.chatId && message) {
this.loader.handleNewMessage(message);
}
}
/**
* @private
*/
_handleWsMessageEdited(detail) {
const { message, chatId } = detail;
if (chatId === this.chatId && message) {
this.loader.handleMessageEdited({ message });
}
}
/**
* @private
*/
_handleWsMessageRemoved(detail) {
const { messageId, chatId } = detail;
if (chatId === this.chatId && messageId) {
this.loader.handleMessageRemoved({ message_id: messageId });
}
}
/**
* @private
*/
_handleWsReaction(detail) {
const { messageId, reactionsSummary, chatId } = detail;
if (chatId === this.chatId && messageId) {
this.loader.handleReactionChange({
message_id: messageId,
reactions_summary: reactionsSummary
});
}
}
// ==================== Приватные методы - Утилиты ====================
/**
* Валидирует опции конструктора
* @private
*/
_validateOptions(options) {
if (!options.chatId) {
throw new Error('[ChatControllerV2] chatId is required');
}
if (!options.scrollElement) {
throw new Error('[ChatControllerV2] scrollElement is required');
}
if (!options.currentUserId) {
throw new Error('[ChatControllerV2] currentUserId is required');
}
}
/**
* Прокручивает к сообщению
* @private
*/
async _scrollToMessage(messageId) {
if (!messageId) return false;
// Ждем завершения рендера
await new Promise(resolve => setTimeout(resolve, SCROLL_CONFIG.SCROLL_RESTORE_DELAY));
const messageEl = this.scrollElement.querySelector(`[data-message-id="${messageId}"]`);
if (messageEl) {
// ✅ КРИТИЧНО: НЕ используем scrollIntoView() - он скроллит родителей!
// Вместо этого вручную скроллим только scrollElement
const containerRect = this.scrollElement.getBoundingClientRect();
const messageRect = messageEl.getBoundingClientRect();
const relativeTop = messageRect.top - containerRect.top;
// Учитываем sticky header (56px + ~40px для sticky date)
const stickyHeaderOffset = 96;
const targetScrollTop = this.scrollElement.scrollTop + relativeTop - (this.scrollElement.clientHeight / 2) + (messageRect.height / 2);
// Дополнительно вычитаем offset если сообщение будет в верхней части
const adjustedScrollTop = relativeTop < this.scrollElement.clientHeight / 2
? targetScrollTop - stickyHeaderOffset
: targetScrollTop;
// 🚫 REMOVED: Автоскролл к сообщению после loadAround
// loadAround уже загрузил контекст, сообщение видно
// Принудительное центрирование мешает естественной навигации
console.log('[ChatControllerV2] _scrollToMessage removed - context loaded, no forced scroll');
console.log('[ChatControllerV2] Scrolled to message with sticky header adjustment:', {
messageId,
relativeTop,
stickyHeaderOffset,
targetScrollTop,
adjustedScrollTop
});
return true;
}
return false;
}
/**
* Отправляет событие
* @private
*/
_emit(eventName, detail) {
window.dispatchEvent(new CustomEvent(eventName, { detail }));
}
// ==================== Умный автоскролл ====================
/**
* Показывает индикатор новых сообщений
* @private
*/
_showNewMessagesIndicator() {
// Ищем или создаём кнопку
if (!this._newMessagesBtn) {
this._newMessagesBtn = this._findOrCreateNewMessagesButton();
}
if (!this._newMessagesBtn) {
console.warn('[ChatControllerV2] New messages button not found');
return;
}
// Увеличиваем счётчик
this._newMessagesCount++;
// Обновляем текст кнопки
const badge = this._newMessagesBtn.querySelector('.badge');
if (badge) {
badge.textContent = this._newMessagesCount;
}
// Показываем кнопку
this._newMessagesBtn.style.display = 'flex';
this._newMessagesBtn.classList.add('show');
}
/**
* Скрывает индикатор новых сообщений
* @private
*/
_hideNewMessagesIndicator() {
if (!this._newMessagesBtn) {
return;
}
// Сбрасываем счётчик
this._newMessagesCount = 0;
// Обновляем текст
const badge = this._newMessagesBtn.querySelector('.badge');
if (badge) {
badge.textContent = '0';
}
// Скрываем кнопку
this._newMessagesBtn.style.display = 'none';
this._newMessagesBtn.classList.remove('show');
}
/**
* Находит или создаёт кнопку новых сообщений
* @private
* @returns {HTMLElement|null}
*/
_findOrCreateNewMessagesButton() {
// Ищем существующую кнопку
let btn = document.getElementById(AUTOSCROLL_CONFIG.NEW_MESSAGES_BTN_ID);
if (btn) {
return btn;
}
// Создаём новую кнопку
btn = document.createElement('button');
btn.id = AUTOSCROLL_CONFIG.NEW_MESSAGES_BTN_ID;
btn.className = AUTOSCROLL_CONFIG.NEW_MESSAGES_BTN_CLASS;
btn.style.display = 'none';
btn.innerHTML = `
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M10 14L10 6M10 14L6 10M10 14L14 10" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
<span class="badge">0</span> новых сообщения
`;
// Добавляем обработчик клика
btn.addEventListener('click', () => {
this.scrollManager.scrollToBottom({
instant: !AUTOSCROLL_CONFIG.SMOOTH_SCROLL_ON_CLICK,
force: true
});
this._hideNewMessagesIndicator();
});
// Добавляем в DOM
const chatContainer = this.scrollElement.parentElement || document.body;
chatContainer.appendChild(btn);
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
*/
_initScrollWatcher() {
// Слушаем скролл для автоматического скрытия индикатора
const scrollHandler = () => {
if (this.scrollManager.isNearBottom() && this._newMessagesCount > 0) {
this._hideNewMessagesIndicator();
}
};
this.scrollElement.addEventListener('scroll', scrollHandler);
// Сохраняем handler для cleanup
this._scrollWatcherCleanup = () => {
this.scrollElement.removeEventListener('scroll', scrollHandler);
};
}
}
export default ChatControllerV2;