/** * @fileoverview MessageRenderer V2 - рендеринг из Store * @module renderers/messageRendererV2 * * НОВАЯ АРХИТЕКТУРА: * - Рендерит только из MessageStore * - День-разделители вычисляются Store * - Incremental updates (патчинг DOM) * - Нет дублирования логики */ import { DateDividerManager } from '../utils/dateDividers.js'; import { DateGroupManager } from '../managers/dateGroupManager.js'; /** * Экранирует HTML для безопасного вывода * @param {string} text - Текст для экранирования * @returns {string} */ function escapeHtml(text) { if (!text) return ''; const map = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }; return String(text).replace(/[&<>"']/g, m => map[m]); } /** * MessageRenderer V2 - рендерит сообщения из Store */ export class MessageRendererV2 { /** * @param {Object} options - Опции конфигурации * @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 шаблон детальной информации */ constructor(options) { if (!options.store) { throw new Error('[MessageRendererV2] MessageStore is required'); } this.store = options.store; this.containerId = options.containerId || 'chatScroll'; this.currentUserId = options.currentUserId; this.dateGroupManager = options.dateGroupManager || null; this.profileUrl = options.profileUrl || '/employees/profile/'; this.detailUrlTemplate = options.detailUrlTemplate || '/employees/detail/0/'; /** @type {Set} ID сообщений которые уже в DOM */ this.renderedMessages = new Set(); console.log('[MessageRendererV2] Initialized', { hasDateGroupManager: !!this.dateGroupManager }); } // ==================== Основные методы рендеринга ==================== /** * Рендерит ВСЕ сообщения чата (full render) * @param {number} chatId - ID чата * @param {Object} options - Опции * @param {boolean} [options.clear=true] - Очистить контейнер перед рендерингом * @returns {Promise} */ async render(chatId, options = {}) { const container = document.getElementById(this.containerId); if (!container) { console.error('[MessageRendererV2] Container not found:', this.containerId); return; } const { clear = true } = options; // Получаем сообщения из Store (без dividers, groupAndRender сам добавит) const messages = this.store.getMessagesForChat(chatId); if (messages.length === 0) { if (clear) { container.innerHTML = ''; this.renderedMessages.clear(); } return; } // Очищаем ДО рендеринга 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 === '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); fragment.appendChild(messageEl); this.renderedMessages.add(item.message.id); } } }); // ОДНА вставка в DOM container.appendChild(fragment); console.log('[MessageRendererV2] Rendered', items.length, 'items (legacy mode)'); } /** * Добавляет старые сообщения в НАЧАЛО (для истории) * @param {Array} messages - Массив сообщений (от старых к новым) * @param {number} chatId - ID чата * @returns {DocumentFragment} Fragment с элементами для prepend */ prependMessages(messages, chatId) { console.log('[MessageRendererV2] ======================================'); console.log('[MessageRendererV2] prependMessages called:', { messagesCount: messages?.length || 0, chatId, messageIds: messages?.map(m => m.id) || [] }); if (!messages || messages.length === 0) { console.log('[MessageRendererV2] ❌ No messages to prepend'); console.log('[MessageRendererV2] ======================================'); return document.createDocumentFragment(); } 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 = 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 render:', { 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:', item.message.id); } } }); console.log('[MessageRendererV2] Fragment created:', { added, skipped, fragmentChildren: fragment.childElementCount }); console.log('[MessageRendererV2] ======================================'); return fragment; } /** * Добавляет новые сообщения в КОНЕЦ (для загрузки при прокрутке вниз после перехода по дате) * Как в Telegram: при прокрутке вниз после перехода к старой дате * @param {Array} 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 - Контейнер с сообщениями */ _removeDuplicateDividers(container) { const dividers = Array.from(container.querySelectorAll('.day-divider')); const seen = new Set(); dividers.forEach(divider => { const text = divider.textContent.trim(); if (seen.has(text)) { // Удаляем дубликат divider.remove(); console.log('[MessageRendererV2] Removed duplicate divider:', text); } else { seen.add(text); } }); } /** * Добавляет одно новое сообщение в конец (incremental) * @param {Object} message - Сообщение * @param {number} chatId - ID чата */ appendMessage(message, chatId) { if (!message || !message.id) { console.error('[MessageRendererV2] Invalid message:', message); return; } // Проверяем дубликат if (this.renderedMessages.has(message.id)) { console.log('[MessageRendererV2] Message already rendered:', message.id); return; } const container = document.getElementById(this.containerId); if (!container) { console.error('[MessageRendererV2] Container not found:', this.containerId); return; } 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'); if (lastMessage) { const lastMsgId = lastMessage.dataset.messageId; const lastMsgData = this.store.getMessage(lastMsgId); if (lastMsgData) { const lastMsgDate = new Date(lastMsgData.created_ts); const lastMsgDay = DateDividerManager.formatDay(lastMsgDate); const msgDate = new Date(message.created_ts); const msgDay = DateDividerManager.formatDay(msgDate); // Добавляем divider только если день изменился if (msgDay !== lastMsgDay) { container.appendChild(DateDividerManager.createDivider(msgDay)); } } } else { // Первое сообщение - добавляем divider const msgDate = new Date(message.created_ts); const msgDay = DateDividerManager.formatDay(msgDate); container.appendChild(DateDividerManager.createDivider(msgDay)); } // Создаем и добавляем сообщение const messageEl = this._createMessageElement(message); container.appendChild(messageEl); this.renderedMessages.add(message.id); } /** * Обновляет существующее сообщение (патчинг) * @param {number|string} messageId - ID сообщения * @param {Partial} updates - Обновления */ updateMessage(messageId, updates) { const messageEl = document.querySelector(`[data-message-id="${messageId}"]`); if (!messageEl) { console.warn('[MessageRendererV2] Message element not found:', messageId); return; } console.log('[MessageRendererV2] Updating message:', messageId, updates); // Патчим только измененные части if (updates.content !== undefined) { const contentEl = messageEl.querySelector('.message-content'); if (contentEl) { contentEl.textContent = updates.content; } } if (updates.is_edited !== undefined && updates.is_edited) { messageEl.setAttribute('data-is-edited', 'true'); if (updates.edited_at) { messageEl.setAttribute('data-edited-at', String(updates.edited_at)); } // Добавляем индикатор редактирования const contentEl = messageEl.querySelector('.message-content'); if (contentEl && !contentEl.querySelector('.edited-indicator')) { const indicator = document.createElement('small'); indicator.className = 'edited-indicator text-muted ms-1'; indicator.textContent = '(ред.)'; contentEl.appendChild(indicator); } } if (updates.reactions_summary !== undefined) { this._updateReactions(messageEl, updates.reactions_summary); } if (updates.status !== undefined) { this._updateMessageStatus(messageEl, updates.status); } } /** * Удаляет сообщение из DOM * @param {number|string} messageId - ID сообщения */ removeMessage(messageId) { const messageEl = document.querySelector(`[data-message-id="${messageId}"]`); if (!messageEl) { console.warn('[MessageRendererV2] Message element not found:', messageId); return; } console.log('[MessageRendererV2] Removing message:', messageId); messageEl.remove(); this.renderedMessages.delete(messageId); } // ==================== Создание элементов ==================== /** * Создает элемент сообщения * @private */ _createMessageElement(msg) { const isOwn = msg.author_id === this.currentUserId; const messageEl = document.createElement('div'); // Классы const classes = ['d-flex', 'mb-3', 'msg']; classes.push(isOwn ? 'justify-content-end' : 'justify-content-start'); if (msg.status === 'sending') { classes.push('message-pending'); } else if (msg.status === 'failed') { classes.push('message-failed'); } messageEl.className = classes.join(' '); // Атрибуты messageEl.setAttribute('data-id', String(msg.id)); messageEl.setAttribute('data-message-id', String(msg.id)); messageEl.setAttribute('data-ts', String(msg.created_ts)); messageEl.setAttribute('data-author-id', String(msg.author_id || '')); messageEl.setAttribute('data-is-edited', String(msg.is_edited || false)); if (msg.edited_at) { messageEl.setAttribute('data-edited-at', String(msg.edited_at)); } if (msg.reactions_summary) { messageEl.setAttribute('data-reactions', JSON.stringify(msg.reactions_summary)); } // HTML содержимое messageEl.innerHTML = this._buildMessageInnerHtml(msg, isOwn); return messageEl; } /** * Создает внутренний HTML сообщения * @private */ _buildMessageInnerHtml(msg, isOwn) { const authorName = escapeHtml(msg.author_name || 'Неизвестный'); const content = escapeHtml(msg.content || ''); const time = this._formatTime(msg.created_ts); // Определяем URL автора let authorUrl = this.profileUrl; if (msg.author_id && this.detailUrlTemplate) { authorUrl = this.detailUrlTemplate.replace('/0/', `/${msg.author_id}/`); } // Базовая структура let html = `
${!isOwn ? ` ` : ''}
${content}${msg.is_edited ? '(ред.)' : ''}
${msg.attachments && msg.attachments.length > 0 ? `
${msg.attachments.map(att => this._renderAttachment(att, isOwn)).join('')}
` : ''}
${time} ${msg.status === 'sending' ? '' : ''} ${msg.status === 'failed' ? '' : ''}
${msg.reactions_summary && Object.keys(msg.reactions_summary).length > 0 ? `
${this._renderReactions(msg.reactions_summary)}
` : ''}
`; return html; } /** * Рендерит вложения * @private */ _renderAttachment(att, isOwn) { if (!att) return ''; const fileName = escapeHtml(att.file_name || att.name || 'Файл'); const fileUrl = att.file_url || att.url || '#'; const fileSize = att.file_size || att.size || 0; const fileType = att.file_type || att.type || ''; // Форматируем размер const sizeStr = fileSize > 0 ? `${(fileSize / 1024).toFixed(1)} KB` : ''; // Определяем иконку по типу let icon = 'bi-file-earmark'; if (fileType.startsWith('image/')) { icon = 'bi-file-image'; } else if (fileType.startsWith('video/')) { icon = 'bi-file-play'; } else if (fileType.includes('pdf')) { icon = 'bi-file-pdf'; } else if (fileType.includes('word') || fileType.includes('document')) { icon = 'bi-file-word'; } return ` ${fileName} ${sizeStr ? `(${sizeStr})` : ''} `; } /** * Рендерит реакции * @private */ _renderReactions(reactions) { if (!reactions || typeof reactions !== 'object') return ''; return Object.entries(reactions) .map(([emoji, count]) => { if (count > 0) { return `${escapeHtml(emoji)} ${count}`; } return ''; }) .join(''); } /** * Обновляет реакции сообщения * @private */ _updateReactions(messageEl, reactions) { let reactionsContainer = messageEl.querySelector('.message-reactions'); if (!reactions || Object.keys(reactions).length === 0) { // Удаляем контейнер если нет реакций if (reactionsContainer) { reactionsContainer.remove(); } return; } const reactionsHtml = this._renderReactions(reactions); if (!reactionsContainer) { // Создаем контейнер const bubble = messageEl.querySelector('.message-bubble'); if (bubble) { reactionsContainer = document.createElement('div'); reactionsContainer.className = 'message-reactions mt-2'; bubble.appendChild(reactionsContainer); } } if (reactionsContainer) { reactionsContainer.innerHTML = reactionsHtml; } // Обновляем data-атрибут messageEl.setAttribute('data-reactions', JSON.stringify(reactions)); } /** * Обновляет статус сообщения (sending/sent/failed) * @private */ _updateMessageStatus(messageEl, status) { // Убираем все статусные классы messageEl.classList.remove('message-pending', 'message-failed'); // Добавляем нужный if (status === 'sending') { messageEl.classList.add('message-pending'); } else if (status === 'failed') { messageEl.classList.add('message-failed'); } // Обновляем иконку в времени const timeEl = messageEl.querySelector('.message-time'); if (timeEl) { // Убираем старые иконки const icons = timeEl.querySelectorAll('i'); icons.forEach(icon => icon.remove()); // Добавляем новую if (status === 'sending') { timeEl.innerHTML += ' '; } else if (status === 'failed') { timeEl.innerHTML += ' '; } } } // ==================== Утилиты ==================== /** * Форматирует время * @private */ _formatTime(timestamp) { const date = new Date(timestamp); const hours = String(date.getHours()).padStart(2, '0'); const minutes = String(date.getMinutes()).padStart(2, '0'); return `${hours}:${minutes}`; } /** * Очищает контейнер */ clear() { const container = document.getElementById(this.containerId); if (container) { container.innerHTML = ''; this.renderedMessages.clear(); // Очищаем DateGroupManager если есть if (this.dateGroupManager) { this.dateGroupManager.clear(); } console.log('[MessageRendererV2] Container cleared'); } } /** * Проверяет отрендерено ли сообщение * @param {number|string} messageId - ID сообщения * @returns {boolean} */ isRendered(messageId) { return this.renderedMessages.has(messageId); } /** * Получает статистику * @returns {Object} */ getStats() { return { renderedCount: this.renderedMessages.size, containerId: this.containerId }; } } export default MessageRendererV2;