Corp/backend/static/js/controllers/chatControllerV2.js
Игорь Надеин 682714ba15 refactor: Clean architecture + unified badge styling
PART 1: Extract UnreadBadgeManager (Clean Architecture)

Created managers/unreadBadgeManager.js:
✅ Single Responsibility: Manages ONLY badge UI
✅ Methods: increment(), reset(), setCount(), hasUnread()
✅ Handles all DOM manipulation internally
✅ Reusable and testable

Cleaned ChatControllerV2:
❌ Removed 150+ lines of UI logic
❌ Removed duplicate systems (_unreadCount + _newMessagesCount)
❌ Removed deprecated methods:
   - _incrementUnreadBadge()
   - _resetUnreadBadge()
   - _showNewMessagesIndicator()
   - _hideNewMessagesIndicator()
   - _findOrCreateNewMessagesButton()
✅ Now uses: this.badgeManager.increment/reset()

PART 2: Unified Badge Styling

Aligned unread-badge with sticky-date design:
- Same typography (font-size, weight, line-height)
- Same spacing (padding: 6px 12px)
- Same border-radius (10-12px)
- Same shadows (box-shadow hierarchy)
- Same transitions (0.2s ease)
- Same animation patterns (scale, hover effects)
- Added border: 2px for contrast with background

Result:
✅ Consistent UI across all badges
✅ Matches Telegram-like design system
✅ Better visual hierarchy
✅ ~950 lines in Controller (was 1125)
✅ Clean separation of concerns
2026-01-13 15:36:17 +03:00

996 lines
40 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 { UnreadBadgeManager } from '../managers/unreadBadgeManager.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;
// Badge Manager для непрочитанных сообщений
this.badgeManager = new UnreadBadgeManager('scrollBtn');
// Слушатели
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) {
// Есть lastRead - СРАЗУ позиционируем на него
await this._positionToMessage(loadResult.anchorId);
} else {
// Нет anchor - позиционируем в самый низ (новый чат)
await new Promise(resolve => {
requestAnimationFrame(() => {
this.scrollElement.scrollTop = this.scrollElement.scrollHeight;
console.log('[ChatControllerV2] Initial position: bottom (no anchor)');
resolve();
});
});
}
// 6. Инициализируем ScrollManager
await this.scrollManager.init();
// 7. Инициализируем StickyDateObserver
this.stickyDateObserver.init();
// 8. Настраиваем обработчик кликов на даты
this._setupDateClickHandler();
// 9. Инициализируем умную кнопку скролла
this._initScrollButton();
// 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 });
});
// Слушаем событие скролла вниз для сброса badge
const scrollBottomHandler = (e) => {
if (e.detail.chatId === this.chatId) {
this.badgeManager.reset();
}
};
window.addEventListener('chat:userScrolledToBottom', scrollBottomHandler);
this._eventListeners.push({ event: 'chat:userScrolledToBottom', handler: scrollBottomHandler });
}
/**
* @private
*/
_handleWsNewMessage(detail) {
const { message, chatId } = detail;
if (chatId === this.chatId && message) {
const boundaries = this.loader.chatBoundaries.get(chatId);
const isInPresentTime = boundaries && !boundaries.hasMoreAfter;
if (isInPresentTime) {
// ✅ Настоящее время (hasMoreAfter=false) - обрабатываем как обычно
this.loader.handleNewMessage(message);
const isNearBottom = this.scrollManager.isNearBottom();
if (isNearBottom) {
// ✅ Пользователь внизу → автоскролл к новому сообщению
requestAnimationFrame(() => {
this.scrollElement.scrollTop = this.scrollElement.scrollHeight;
console.log('[ChatControllerV2] Auto-scrolled to new message (user at bottom)');
});
} else {
// ✅ Пользователь НЕ внизу → показываем badge
this.badgeManager.increment();
}
} else {
// ❌ Исторический просмотр (hasMoreAfter=true) - НЕ рендерим, только badge
// Не добавляем в Store (не рендерим), только показываем что есть новые
this.badgeManager.increment();
console.log('[ChatControllerV2] New message in historical view - showing badge only');
}
}
}
/**
* @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');
}
}
/**
* 🆕 Мгновенно позиционирует viewport на сообщение БЕЗ скроллинга
* Вызывается ТОЛЬКО при начальной загрузке
* @private
*/
async _positionToMessage(messageId) {
if (!messageId) return false;
// Ждем один RAF для надежности
await new Promise(resolve => requestAnimationFrame(resolve));
const messageEl = this.scrollElement.querySelector(`[data-message-id="${messageId}"]`);
if (messageEl) {
// Вычисляем позицию для центрирования сообщения
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;
// ✨ МГНОВЕННАЯ установка позиции - НЕ скролл, а начальная позиция!
this.scrollElement.scrollTop = Math.max(0, adjustedScrollTop);
console.log('[ChatControllerV2] Initial position set to message:', {
messageId,
scrollTop: this.scrollElement.scrollTop,
relativeTop,
noScrollAnimation: true
});
return true;
}
console.warn('[ChatControllerV2] Message not found for positioning:', messageId);
return false;
}
/**
* Прокручивает к сообщению
* @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 }));
}
// ==================== Умный scrollBtn с badge ====================
/**
* Инициализирует умную кнопку скролла
* @private
*/
_initScrollButton() {
const scrollBtn = document.getElementById('scrollBtn');
if (!scrollBtn) {
console.warn('[ChatControllerV2] #scrollBtn not found');
return;
}
// Заменяем обработчик клика на умный
scrollBtn.onclick = null;
scrollBtn.addEventListener('click', () => this._handleScrollButtonClick());
// Настраиваем умную видимость
this._setupSmartButtonVisibility();
console.log('[ChatControllerV2] Smart scroll button initialized');
}
/**
* Настраивает умную видимость кнопки
* @private
*/
_setupSmartButtonVisibility() {
const scrollBtn = document.getElementById('scrollBtn');
if (!scrollBtn) return;
// Обновляем видимость при скролле
const updateVisibility = () => {
const boundaries = this.loader.chatBoundaries.get(this.chatId);
const hasMoreAfter = boundaries?.hasMoreAfter || false;
const isNearBottom = this.scrollManager.isNearBottom();
// Показываем кнопку если:
// 1. Пользователь НЕ внизу (любой случай)
// 2. ИЛИ hasMoreAfter=true (даже если внизу)
const shouldShow = !isNearBottom || hasMoreAfter;
scrollBtn.classList.toggle('show', shouldShow);
};
// Обновляем при скролле
this.scrollElement.addEventListener('scroll', updateVisibility);
// Обновляем при загрузке новых сообщений
window.addEventListener('chat:messagesLoaded', updateVisibility);
// Начальное обновление
setTimeout(updateVisibility, 100);
}
/**
* Обработчик клика на умную кнопку
* @private
*/
async _handleScrollButtonClick() {
const boundaries = this.loader.chatBoundaries.get(this.chatId);
const hasMoreAfter = boundaries?.hasMoreAfter || false;
if (hasMoreAfter) {
// 📥 ИСТОРИЧЕСКИЙ НИЗ: Загружаем всю историю до конца
console.log('[ChatControllerV2] Loading history to present time...');
try {
// Загружаем последние сообщения (настоящее время)
const result = await this.loader.loadLatest(this.chatId, 50);
// Рендерим новые сообщения
await this.renderer.render(this.chatId);
// Скроллим в низ
await new Promise(resolve => requestAnimationFrame(resolve));
this.scrollElement.scrollTop = this.scrollElement.scrollHeight;
console.log('[ChatControllerV2] Loaded to present, messages:', result.messages.length);
} catch (error) {
console.error('[ChatControllerV2] Failed to load history:', error);
// Если ошибка - просто скроллим в низ загруженного
this.scrollElement.scrollTop = this.scrollElement.scrollHeight;
}
// Сбрасываем badge
this.badgeManager.reset();
return;
}
// 🔵 НАСТОЯЩИЙ НИЗ: Умная логика
const isInPresentTime = !hasMoreAfter;
if (this.badgeManager.hasUnread() && this.lastReadMessageId && isInPresentTime) {
// Режим "Перейти к непрочитанным"
// Пытаемся найти первое непрочитанное (lastReadId + 1)
const firstUnreadId = this.lastReadMessageId + 1;
const firstUnreadEl = this.scrollElement.querySelector(`[data-message-id="${firstUnreadId}"]`);
if (firstUnreadEl) {
// Скроллим к первому непрочитанному
const containerRect = this.scrollElement.getBoundingClientRect();
const messageRect = firstUnreadEl.getBoundingClientRect();
const relativeTop = messageRect.top - containerRect.top;
const targetScrollTop = this.scrollElement.scrollTop + relativeTop - 100;
this.scrollElement.scrollTop = Math.max(0, targetScrollTop);
console.log('[ChatControllerV2] Scrolled to first unread:', firstUnreadId);
} else {
// Не нашли первое непрочитанное - просто в низ
this.scrollElement.scrollTop = this.scrollElement.scrollHeight;
}
} else {
// Обычный режим "В самый низ"
this.scrollElement.scrollTop = this.scrollElement.scrollHeight;
console.log('[ChatControllerV2] Scrolled to bottom');
}
// Сбрасываем badge
this.badgeManager.reset();
}
/**
* Настраивает обработчик кликов на даты
* @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;