- Восстановлен мягкий цвет пузырей: color-mix(92%) вместо жесткого var(--bs-primary) - Аватары: border-radius 9999px + grid layout для идеальных кругов - Разделители: правильный spacing, font-weight 700, pointer-events, color-mix прозрачность - Добавлены оптимизации скролла: rootMargin 300px, limit 50 сообщений (Telegram-стиль) - Добавлены новые модули: messageLoader, scrollManager, chatUtils - Рефакторинг логики загрузки сообщений для предотвращения дубликатов - Создана документация по архитектуре и тестированию
563 lines
22 KiB
JavaScript
563 lines
22 KiB
JavaScript
/**
|
||
* @fileoverview ChatController - главный координатор чата
|
||
* @module controllers/chatController
|
||
*
|
||
* Единая точка входа для работы с чатом.
|
||
* Координирует все компоненты: Store, Loader, Renderer, ScrollManager
|
||
*/
|
||
|
||
import { MessageStore } from '../stores/messageStore.js';
|
||
import { MessageLoader } from '../loaders/messageLoader.js';
|
||
import { MessageRendererV2 } from '../renderers/messageRendererV2.js';
|
||
import { ScrollManager } from '../managers/scrollManager.js';
|
||
|
||
/**
|
||
* ChatController - координатор чата
|
||
*/
|
||
export class ChatController {
|
||
/**
|
||
* @param {Object} options - Опции конфигурации
|
||
* @param {number} options.chatId - ID чата
|
||
* @param {HTMLElement} options.scrollElement - Контейнер для скролла
|
||
* @param {string} options.containerId - ID контейнера сообщений
|
||
* @param {number} options.currentUserId - ID текущего пользователя
|
||
* @param {Object} options.wsConnection - WebSocket соединение
|
||
* @param {string} [options.profileUrl] - URL профиля
|
||
* @param {string} [options.detailUrlTemplate] - URL детальной информации
|
||
* @param {number} [options.lastReadTimestamp] - Timestamp последнего прочитанного сообщения
|
||
*/
|
||
constructor(options) {
|
||
if (!options.chatId) {
|
||
throw new Error('[ChatController] chatId is required');
|
||
}
|
||
if (!options.scrollElement) {
|
||
throw new Error('[ChatController] scrollElement is required');
|
||
}
|
||
if (!options.currentUserId) {
|
||
throw new Error('[ChatController] currentUserId is required');
|
||
}
|
||
|
||
this.chatId = options.chatId;
|
||
this.currentUserId = options.currentUserId;
|
||
this.scrollElement = options.scrollElement;
|
||
this.scrollEl = options.scrollElement; // alias
|
||
this.containerId = options.containerId || 'chatScroll';
|
||
this.lastReadTimestamp = options.lastReadTimestamp || null;
|
||
|
||
console.log('[ChatController] Initializing for chat:', this.chatId);
|
||
|
||
// Создаем компоненты
|
||
this.store = new MessageStore({ currentUserId: this.currentUserId });
|
||
|
||
this.loader = new MessageLoader({
|
||
store: this.store,
|
||
wsConnection: options.wsConnection,
|
||
currentUserId: this.currentUserId
|
||
});
|
||
|
||
this.renderer = new MessageRendererV2({
|
||
store: this.store,
|
||
containerId: this.containerId,
|
||
currentUserId: this.currentUserId,
|
||
profileUrl: options.profileUrl,
|
||
detailUrlTemplate: options.detailUrlTemplate
|
||
});
|
||
|
||
this.scrollManager = new ScrollManager({
|
||
scrollElement: this.scrollElement,
|
||
messageLoader: this.loader,
|
||
messageRenderer: this.renderer,
|
||
messageStore: this.store,
|
||
chatId: this.chatId
|
||
});
|
||
|
||
// Флаг инициализации
|
||
this.initialized = false;
|
||
this.isInitializing = false;
|
||
|
||
// Подписываемся на изменения Store
|
||
this._subscribeToStore();
|
||
|
||
// Подписываемся на WebSocket (если есть)
|
||
if (options.wsConnection) {
|
||
this._subscribeToWebSocket(options.wsConnection);
|
||
}
|
||
|
||
console.log('[ChatController] Created');
|
||
}
|
||
|
||
// ==================== Инициализация ====================
|
||
|
||
/**
|
||
* Инициализирует чат (загружает сообщения, рендерит, скроллит)
|
||
* @param {Object} options - Опции
|
||
* @param {boolean} [options.scrollToBottom=true] - Прокрутить вниз после загрузки
|
||
* @returns {Promise<void>}
|
||
*/
|
||
async init(options = {}) {
|
||
if (this.initialized) {
|
||
console.warn('[ChatController] Already initialized');
|
||
return;
|
||
}
|
||
|
||
try {
|
||
console.log('[ChatController] ========================================');
|
||
console.log('[ChatController] Initializing chat:', this.chatId);
|
||
console.log('[ChatController] lastReadTimestamp:', this.lastReadTimestamp);
|
||
console.log('[ChatController] lastReadTimestamp type:', typeof this.lastReadTimestamp);
|
||
console.log('[ChatController] ========================================');
|
||
|
||
// Устанавливаем флаг что идет начальная загрузка
|
||
this.isInitializing = true;
|
||
|
||
// 1. Загружаем начальные сообщения ВОКРУГ last_read
|
||
console.log('[ChatController] Step 1: Loading messages around last read...');
|
||
const loadResult = await this.loader.loadInitialMessages(this.chatId, {
|
||
lastReadMessageId: this.lastReadTimestamp // Используем как ID (нужно переименовать)
|
||
});
|
||
|
||
console.log('[ChatController] ========================================');
|
||
console.log('[ChatController] Load result:', loadResult);
|
||
console.log('[ChatController] ========================================');
|
||
|
||
// 2. Скрываем контейнер чтобы избежать видимых прыжков
|
||
console.log('[ChatController] Step 2: Hiding container for smooth render...');
|
||
this.scrollEl.style.visibility = 'hidden';
|
||
|
||
try {
|
||
// 3. Рендерим (пользователь не видит процесс)
|
||
console.log('[ChatController] Step 3: Rendering messages...');
|
||
await this.renderer.render(this.chatId);
|
||
|
||
// 4. ВАЖНО: Показываем контейнер ДО скролла (scrollTop не работает на hidden)
|
||
console.log('[ChatController] Step 4: Showing container...');
|
||
this.scrollEl.style.visibility = '';
|
||
|
||
// 5. Прокручиваем к нужной позиции ПОСЛЕ показа контейнера
|
||
console.log('[ChatController] Step 5: Scrolling to position...');
|
||
console.log('[ChatController] Anchor ID from loadResult:', loadResult.anchorId);
|
||
|
||
if (loadResult.anchorId) {
|
||
// Есть anchor - пытаемся скроллить к нему
|
||
const scrolled = await this._scrollToMessage(loadResult.anchorId);
|
||
console.log('[ChatController] Scrolled to anchor message:', loadResult.anchorId, '→', scrolled);
|
||
|
||
if (!scrolled) {
|
||
// Не нашли anchor - оставляем прокрутку как есть (не скроллим автоматически)
|
||
console.log('[ChatController] Anchor not found, keeping scroll position');
|
||
}
|
||
} else {
|
||
// Нет anchor (нет lastReadId) - оставляем прокрутку как есть (НЕ скроллим автоматически вниз)
|
||
console.log('[ChatController] No anchor, keeping scroll position');
|
||
}
|
||
} catch (scrollError) {
|
||
console.error('[ChatController] ❌ Error during init:', scrollError);
|
||
// В случае ошибки всё равно показываем контейнер
|
||
this.scrollEl.style.visibility = '';
|
||
throw scrollError;
|
||
}
|
||
|
||
// 6. Инициализируем ScrollManager ПОСЛЕ скролла
|
||
console.log('[ChatController] Step 6: Initializing ScrollManager...');
|
||
await this.scrollManager.init();
|
||
|
||
this.initialized = true;
|
||
this.isInitializing = false; // Сбрасываем флаг
|
||
console.log('[ChatController] ✅ Initialization complete');
|
||
|
||
// Dispatch события для других компонентов
|
||
this._dispatchEvent('chat:initialized', { chatId: this.chatId });
|
||
|
||
} catch (error) {
|
||
console.error('[ChatController] ❌ Initialization failed:', error);
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Прокручивает к конкретному сообщению
|
||
* @private
|
||
* @param {number} messageId - ID сообщения
|
||
* @param {number} [offset=100] - Отступ сверху в пикселях
|
||
* @returns {Promise<boolean>} true если нашли и прокрутили
|
||
*/
|
||
async _scrollToMessage(messageId, offset = 100) {
|
||
console.log('[ChatController] ========================================');
|
||
console.log('[ChatController] _scrollToMessage called');
|
||
console.log('[ChatController] messageId:', messageId);
|
||
console.log('[ChatController] offset:', offset);
|
||
|
||
if (!messageId) {
|
||
console.log('[ChatController] No messageId provided');
|
||
console.log('[ChatController] ========================================');
|
||
return false;
|
||
}
|
||
|
||
// Даём время на завершение DOM updates и browser reflow
|
||
await new Promise(resolve => setTimeout(resolve, 150));
|
||
|
||
// Ищем элемент в DOM
|
||
const messageEl = this.scrollEl.querySelector(`[data-message-id="${messageId}"]`);
|
||
console.log('[ChatController] Found element:', !!messageEl);
|
||
|
||
if (messageEl) {
|
||
console.log('[ChatController] Element classes:', messageEl.className);
|
||
|
||
// Используем scrollIntoView - надежнее чем ручной scrollTop
|
||
messageEl.scrollIntoView({
|
||
behavior: 'instant', // без анимации
|
||
block: 'center', // в центр viewport
|
||
inline: 'nearest'
|
||
});
|
||
|
||
console.log('[ChatController] ✅ Scrolled to message:', messageId);
|
||
console.log('[ChatController] ========================================');
|
||
return true;
|
||
}
|
||
|
||
if (!messageEl) {
|
||
console.warn('[ChatController] Message element not found in DOM:', messageId);
|
||
console.log('[ChatController] Available message IDs:',
|
||
Array.from(this.scrollEl.querySelectorAll('[data-message-id]'))
|
||
.map(el => el.dataset.messageId).slice(0, 10)
|
||
);
|
||
console.log('[ChatController] ========================================');
|
||
return false;
|
||
}
|
||
|
||
// Прокручиваем к элементу с отступом сверху
|
||
const offsetTop = messageEl.offsetTop - offset;
|
||
const scrollBefore = this.scrollEl.scrollTop;
|
||
this.scrollEl.scrollTop = Math.max(0, offsetTop);
|
||
const scrollAfter = this.scrollEl.scrollTop;
|
||
|
||
console.log('[ChatController] Scroll details:');
|
||
console.log('[ChatController] Element offsetTop:', messageEl.offsetTop);
|
||
console.log('[ChatController] Target scrollTop:', offsetTop);
|
||
console.log('[ChatController] Scroll before:', scrollBefore);
|
||
console.log('[ChatController] Scroll after:', scrollAfter);
|
||
console.log('[ChatController] ✅ Scrolled to message', messageId);
|
||
console.log('[ChatController] ========================================');
|
||
return true;
|
||
}
|
||
|
||
/**
|
||
* Уничтожает контроллер и освобождает ресурсы
|
||
*/
|
||
destroy() {
|
||
console.log('[ChatController] Destroying...');
|
||
|
||
// Останавливаем ScrollManager
|
||
if (this.scrollManager) {
|
||
this.scrollManager.destroy();
|
||
}
|
||
|
||
// Очищаем Store
|
||
if (this.store) {
|
||
this.store.clearChat(this.chatId);
|
||
}
|
||
|
||
// Очищаем рендерер
|
||
if (this.renderer) {
|
||
this.renderer.clear();
|
||
}
|
||
|
||
this.initialized = false;
|
||
console.log('[ChatController] Destroyed');
|
||
}
|
||
|
||
// ==================== Подписки ====================
|
||
|
||
/**
|
||
* Подписывается на изменения Store
|
||
* @private
|
||
*/
|
||
_subscribeToStore() {
|
||
this.store.subscribe((event, data) => {
|
||
this._handleStoreUpdate(event, data);
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Обрабатывает обновления из Store
|
||
* @private
|
||
*/
|
||
_handleStoreUpdate(event, data) {
|
||
console.log('[ChatController] Store update:', event, data);
|
||
|
||
switch (event) {
|
||
case 'message_added':
|
||
this._handleMessageAdded(data);
|
||
break;
|
||
|
||
case 'message_updated':
|
||
this._handleMessageUpdated(data);
|
||
break;
|
||
|
||
case 'message_removed':
|
||
this._handleMessageRemoved(data);
|
||
break;
|
||
|
||
case 'messages_loaded':
|
||
// Batch загрузка - ничего не делаем, render() вызовется вручную
|
||
break;
|
||
|
||
default:
|
||
console.log('[ChatController] Unhandled store event:', event);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Обрабатывает добавление сообщения
|
||
* @private
|
||
*/
|
||
_handleMessageAdded(data) {
|
||
const { message, optimistic } = data;
|
||
|
||
// ВАЖНО: Игнорируем события во время начальной загрузки
|
||
// Начальные сообщения рендерятся через render(), а не через appendMessage
|
||
if (this.isInitializing) {
|
||
console.log('[ChatController] Ignoring message_added during initialization:', message.id);
|
||
return;
|
||
}
|
||
|
||
if (message.chat_id !== this.chatId) {
|
||
// Сообщение для другого чата
|
||
return;
|
||
}
|
||
|
||
// Incremental render - добавляем только новое сообщение
|
||
const wasEmpty = this.renderer.renderedMessages.size === 0;
|
||
this.renderer.appendMessage(message, this.chatId);
|
||
|
||
// Если это первое сообщение, настраиваем Observer
|
||
if (wasEmpty) {
|
||
this.scrollManager.setupIntersectionObserver();
|
||
}
|
||
|
||
// Автоскролл ТОЛЬКО если это наше сообщение (отправили мы)
|
||
// Убрали автоскролл при получении чужих сообщений даже если пользователь внизу
|
||
const shouldScroll = message.author_id === this.currentUserId;
|
||
|
||
if (shouldScroll && !optimistic) {
|
||
// Плавная прокрутка к нашему новому сообщению
|
||
requestAnimationFrame(() => {
|
||
this.scrollEl.scrollTop = this.scrollEl.scrollHeight;
|
||
});
|
||
}
|
||
|
||
// Dispatch события для других компонентов (reactions, context menu, etc.)
|
||
this._dispatchEvent('chat:message-added', {
|
||
messageId: message.id,
|
||
chatId: this.chatId
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Обрабатывает обновление сообщения
|
||
* @private
|
||
*/
|
||
_handleMessageUpdated(data) {
|
||
const { messageId, updates } = data;
|
||
|
||
// Патчим DOM
|
||
this.renderer.updateMessage(messageId, updates);
|
||
|
||
// Dispatch события
|
||
this._dispatchEvent('chat:message-updated', {
|
||
messageId,
|
||
updates,
|
||
chatId: this.chatId
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Обрабатывает удаление сообщения
|
||
* @private
|
||
*/
|
||
_handleMessageRemoved(data) {
|
||
const { messageId } = data;
|
||
|
||
// Удаляем из DOM
|
||
this.renderer.removeMessage(messageId);
|
||
|
||
// Dispatch события
|
||
this._dispatchEvent('chat:message-removed', {
|
||
messageId,
|
||
chatId: this.chatId
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Подписывается на события WebSocket
|
||
* @private
|
||
*/
|
||
_subscribeToWebSocket(ws) {
|
||
// Используем глобальные события вместо прямой подписки
|
||
// Это позволяет userWebSocket оставаться независимым
|
||
|
||
// ВАЖНО: ws:initial-messages НЕ обрабатываем!
|
||
// Initial messages загружаются через HTTP в init()
|
||
// Если обрабатывать WS initial - получим двойной render и прыжки
|
||
|
||
// Новое сообщение
|
||
window.addEventListener('ws:new-message', (event) => {
|
||
const { message, chatId } = event.detail;
|
||
if (chatId === this.chatId && message) {
|
||
console.log('[ChatController] WS: new-message, id=%s', message.id);
|
||
this.loader.handleNewMessage(message);
|
||
}
|
||
});
|
||
|
||
// Редактирование сообщения
|
||
window.addEventListener('ws:message-edited', (event) => {
|
||
const { message, chatId } = event.detail;
|
||
if (chatId === this.chatId && message) {
|
||
console.log('[ChatController] WS: message-edited, id=%s', message.id);
|
||
this.loader.handleMessageEdited({ message });
|
||
}
|
||
});
|
||
|
||
// Удаление сообщения
|
||
window.addEventListener('ws:message-removed', (event) => {
|
||
const { messageId, chatId } = event.detail;
|
||
if (chatId === this.chatId && messageId) {
|
||
console.log('[ChatController] WS: message-removed, id=%s', messageId);
|
||
this.loader.handleMessageRemoved({ message_id: messageId });
|
||
}
|
||
});
|
||
|
||
// Добавление реакции
|
||
window.addEventListener('ws:reaction-added', (event) => {
|
||
const { messageId, reactionsSummary, chatId } = event.detail;
|
||
if (chatId === this.chatId && messageId) {
|
||
console.log('[ChatController] WS: reaction-added, msg=%s', messageId);
|
||
this.loader.handleReactionAdded({
|
||
message_id: messageId,
|
||
reactions_summary: reactionsSummary
|
||
});
|
||
}
|
||
});
|
||
|
||
// Удаление реакции
|
||
window.addEventListener('ws:reaction-removed', (event) => {
|
||
const { messageId, reactionsSummary, chatId } = event.detail;
|
||
if (chatId === this.chatId && messageId) {
|
||
console.log('[ChatController] WS: reaction-removed, msg=%s', messageId);
|
||
this.loader.handleReactionRemoved({
|
||
message_id: messageId,
|
||
reactions_summary: reactionsSummary
|
||
});
|
||
}
|
||
});
|
||
|
||
console.log('[ChatController] ✅ Subscribed to WebSocket events');
|
||
}
|
||
|
||
// ==================== Публичное API ====================
|
||
|
||
/**
|
||
* Отправляет сообщение
|
||
* @param {string} content - Содержимое сообщения
|
||
* @param {Object} options - Дополнительные опции
|
||
* @returns {string} Временный ID сообщения
|
||
*/
|
||
sendMessage(content, options = {}) {
|
||
if (!content || typeof content !== 'string' || content.trim() === '') {
|
||
console.warn('[ChatController] Cannot send empty message');
|
||
return null;
|
||
}
|
||
|
||
console.log('[ChatController] Sending message...');
|
||
|
||
const tempId = this.loader.sendMessageOptimistically(
|
||
this.chatId,
|
||
content.trim(),
|
||
options
|
||
);
|
||
|
||
return tempId;
|
||
}
|
||
|
||
/**
|
||
* Загружает больше истории
|
||
* @returns {Promise<Array>}
|
||
*/
|
||
async loadMoreHistory() {
|
||
return this.scrollManager.loadMoreHistory();
|
||
}
|
||
|
||
/**
|
||
* Прокручивает к последнему сообщению
|
||
* @param {Object} options - Опции
|
||
*/
|
||
scrollToBottom(options = {}) {
|
||
this.scrollManager.scrollToBottom(options);
|
||
}
|
||
|
||
/**
|
||
* Прокручивает к конкретному сообщению
|
||
* @param {number|string} messageId - ID сообщения
|
||
* @param {Object} options - Опции
|
||
*/
|
||
scrollToMessage(messageId, options = {}) {
|
||
this.scrollManager.scrollToMessage(messageId, options);
|
||
}
|
||
|
||
/**
|
||
* Получает сообщение по ID
|
||
* @param {number|string} messageId - ID сообщения
|
||
* @returns {Message|null}
|
||
*/
|
||
getMessage(messageId) {
|
||
return this.store.getMessage(messageId);
|
||
}
|
||
|
||
/**
|
||
* Получает все сообщения чата
|
||
* @param {Object} options - Опции
|
||
* @returns {Array<Message>}
|
||
*/
|
||
getMessages(options = {}) {
|
||
return this.store.getMessagesForChat(this.chatId, options);
|
||
}
|
||
|
||
/**
|
||
* Проверяет находится ли пользователь внизу
|
||
* @returns {boolean}
|
||
*/
|
||
isNearBottom() {
|
||
return this.scrollManager.isNearBottom();
|
||
}
|
||
|
||
/**
|
||
* Получает статус чата
|
||
* @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()
|
||
};
|
||
}
|
||
|
||
// ==================== Утилиты ====================
|
||
|
||
/**
|
||
* Отправляет кастомное событие
|
||
* @private
|
||
*/
|
||
_dispatchEvent(eventName, detail) {
|
||
window.dispatchEvent(new CustomEvent(eventName, { detail }));
|
||
}
|
||
}
|
||
|
||
// Экспорт для использования в других модулях
|
||
export default ChatController;
|