Corp/backend/static/js/renderers/messageRendererV2.js
Игорь Надеин 759b7e8b0f feat(chat): реализован рендеринг изображений и видео в чате
Проблема:
- Изображения и видео отображались только как ссылки с иконками
- Нет preview изображений в composer
- Вся инфраструктура существовала (DB поля, CSS, WebSocket), но не была подключена

Реализация:

messageRendererV2.js (_renderAttachment):
- Добавлена проверка file_type === 'image': рендеринг <img class="chat-media chat-media--image">
- Добавлена проверка file_type === 'video': рендеринг <video class="chat-media chat-media--video"> с controls
- Используется thumbnail URL для изображений (если доступен), иначе file_url
- Атрибуты: loading="lazy", playsinline, preload="metadata" для оптимизации
- Файлы (PDF, Word, Audio) рендерятся как ссылки с иконками

chatComposer.js (renderPreview):
- Для изображений: показываем thumbnail через URL.createObjectURL
- Для других файлов: иконка (как раньше)
- Добавлен URL.revokeObjectURL при удалении для предотвращения утечек памяти
- Preview размеры: 60x60px с border-radius и object-fit: cover

CSS (_chat-detail.scss):
- Все стили .chat-media уже были готовы:
  * max-width: min(640px, 85vw)
  * border-radius: 12px
  * object-fit: cover (изображения) / contain (видео)
  * Responsive sizing для мобильных
- Стили применяются автоматически при рендеринге

Технические детали:
- WebSocket уже передает: file_type, file_url, thumbnail, mime_type
- Database модель MessageAttachment поддерживает thumbnail поле
- CSS классы .chat-media--image и .chat-media--video определены
- Lazy loading для изображений (атрибут loading="lazy")
- Video с preload="metadata" для быстрой загрузки первого кадра

Тестирование: Требуется загрузка реальных изображений/видео для проверки
2026-01-14 11:22:15 +03:00

857 lines
33 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 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 = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#039;'
};
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<number|string>} 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<void>}
*/
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<Object>} 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<Object>} messages - Массив сообщений (от старых к новым)
* @param {number} chatId - ID чата
* @returns {DocumentFragment} Fragment с элементами для append
*/
appendMessages(messages, chatId) {
console.log('[MessageRendererV2] ======================================');
console.log('[MessageRendererV2] appendMessages called:', {
messagesCount: messages?.length || 0,
chatId,
messageIds: messages?.map(m => m.id) || []
});
if (!messages || messages.length === 0) {
console.log('[MessageRendererV2] ❌ No messages to append');
console.log('[MessageRendererV2] ======================================');
return document.createDocumentFragment();
}
console.log('[MessageRendererV2] Appending', messages.length, 'messages');
const fragment = document.createDocumentFragment();
// Если есть DateGroupManager - используем группировку
if (this.dateGroupManager) {
console.log('[MessageRendererV2] Using DateGroupManager for append');
// Создаем группы с помощью DateGroupManager
const groups = this.dateGroupManager.groupAndRender(
messages,
(msg) => {
if (!this.renderedMessages.has(msg.id)) {
this.renderedMessages.add(msg.id);
return this._createMessageElement(msg);
}
return null;
},
'append' // Указываем что это append
);
// Добавляем все группы в fragment
groups.forEach(group => fragment.appendChild(group));
console.log('[MessageRendererV2] Created', groups.length, 'date groups for append');
console.log('[MessageRendererV2] ======================================');
return fragment;
}
// Fallback: старая логика без групп
console.log('[MessageRendererV2] Using legacy dividers for append (no DateGroupManager)');
const items = [];
// Получаем последний день из контейнера для корректных divider'ов
const container = document.getElementById(this.containerId);
const lastDivider = container?.querySelector('.day-divider:last-of-type');
let lastDay = lastDivider ? lastDivider.textContent.trim() : null;
// Группируем сообщения по дням (от старых к новым)
messages.forEach(msg => {
const msgDate = new Date(msg.created_ts);
const msgDay = DateDividerManager.formatDay(msgDate);
// Добавляем divider если день изменился
if (msgDay !== lastDay) {
items.push({ type: 'day-divider', text: msgDay });
lastDay = msgDay;
}
items.push({ type: 'message', message: msg });
});
console.log('[MessageRendererV2] Items to append:', {
totalItems: items.length,
dividers: items.filter(i => i.type === 'day-divider').length,
messages: items.filter(i => i.type === 'message').length
});
// Создаем элементы
let skipped = 0;
let added = 0;
items.forEach(item => {
if (item.type === 'day-divider') {
fragment.appendChild(DateDividerManager.createDivider(item.text));
added++;
} else if (item.type === 'message') {
if (!this.renderedMessages.has(item.message.id)) {
const messageEl = this._createMessageElement(item.message);
fragment.appendChild(messageEl);
this.renderedMessages.add(item.message.id);
added++;
} else {
skipped++;
console.log('[MessageRendererV2] ⚠️ Skipped duplicate in append:', item.message.id);
}
}
});
console.log('[MessageRendererV2] Append fragment created:', {
added,
skipped,
fragmentChildren: fragment.childElementCount
});
console.log('[MessageRendererV2] ======================================');
return fragment;
}
/**
* Удаляет дубликаты day-divider после prepend
* @param {HTMLElement} container - Контейнер с сообщениями
*/
_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<Message>} 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 содержимое с аватаром для чужих сообщений
let html = '';
// Аватар для чужих сообщений (слева)
if (!isOwn) {
// Проверяем разные варианты поля с аватаром
const avatarUrl = msg.author_avatar || msg.avatar || msg.author?.avatar || '';
console.log('[MessageRendererV2] Avatar debug:', {
author_avatar: msg.author_avatar,
avatar: msg.avatar,
author: msg.author,
avatarUrl,
message: msg
});
// Всегда создаем контейнер .mini-ava
html += '<div class="mini-ava me-2">';
if (avatarUrl) {
html += `<img src="${avatarUrl}" alt="${escapeHtml(msg.author_name || '')}"/>`;
} else {
html += '<i class="bi bi-person-circle"></i>';
}
html += '</div>';
}
// Bubble с содержимым
html += this._buildMessageInnerHtml(msg, isOwn);
messageEl.innerHTML = html;
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 = '<div class="bubble ' + (isOwn ? 'bubble-me' : 'bubble-other') + ' position-relative">';
// Имя автора для чужих сообщений
if (!isOwn) {
html += '<div class="message-author mb-1">';
html += '<a href="' + authorUrl + '" class="text-decoration-none fw-bold text-primary">' + authorName + '</a>';
html += '</div>';
}
// Контент
html += '<div class="message-content">' + content;
if (msg.is_edited) {
html += '<small class="edited-indicator text-muted ms-1">(ред.)</small>';
}
html += '</div>';
// Вложения
if (msg.attachments && msg.attachments.length > 0) {
html += '<div class="message-attachments mt-2">';
html += msg.attachments.map(att => this._renderAttachment(att, isOwn)).join('');
html += '</div>';
}
// Время
html += '<div class="message-time small ' + (isOwn ? 'text-white-50' : 'text-muted') + ' mt-1">';
html += time;
if (msg.status === 'sending') html += '<i class="bi bi-clock ms-1"></i>';
if (msg.status === 'failed') html += '<i class="bi bi-exclamation-circle text-danger ms-1"></i>';
html += '</div>';
// Реакции
if (msg.reactions_summary && Object.keys(msg.reactions_summary).length > 0) {
html += '<div class="message-reactions mt-2">';
html += this._renderReactions(msg.reactions_summary);
html += '</div>';
}
html += '</div>';
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 thumbnailUrl = att.thumbnail || null;
// Рендеринг изображений
if (fileType === 'image' || fileType.startsWith('image/')) {
const imgSrc = thumbnailUrl || fileUrl;
return `
<a href="${fileUrl}" target="_blank" class="attachment-item d-block">
<img src="${imgSrc}"
alt="${fileName}"
class="chat-media chat-media--image"
loading="lazy"
style="max-width: 100%; height: auto; display: block;" />
</a>
`;
}
// Рендеринг видео
if (fileType === 'video' || fileType.startsWith('video/')) {
return `
<div class="attachment-item">
<video src="${fileUrl}"
class="chat-media chat-media--video"
controls
playsinline
preload="metadata"
style="max-width: 100%; height: auto; display: block;">
Ваш браузер не поддерживает воспроизведение видео.
</video>
</div>
`;
}
// Форматируем размер для файлов
const sizeStr = fileSize > 0
? `${(fileSize / 1024).toFixed(1)} KB`
: '';
// Определяем иконку для файлов
let icon = 'bi-file-earmark';
if (fileType.includes('pdf')) {
icon = 'bi-file-pdf';
} else if (fileType.includes('word') || fileType.includes('document')) {
icon = 'bi-file-word';
} else if (fileType.startsWith('audio/')) {
icon = 'bi-file-music';
}
// Рендеринг обычных файлов (документы, аудио и т.д.)
return `
<a href="${fileUrl}"
target="_blank"
class="attachment-link d-inline-flex align-items-center text-decoration-none ${isOwn ? 'text-white' : 'text-dark'} mb-1">
<i class="bi ${icon} me-2"></i>
<span class="attachment-name">${fileName}</span>
${sizeStr ? `<span class="attachment-size ms-2 small opacity-75">(${sizeStr})</span>` : ''}
</a>
`;
}
/**
* Рендерит реакции
* @private
*/
_renderReactions(reactions) {
if (!reactions || typeof reactions !== 'object') return '';
return Object.entries(reactions)
.map(([emoji, count]) => {
if (count > 0) {
return `<span class="reaction-badge badge bg-secondary me-1">${escapeHtml(emoji)} ${count}</span>`;
}
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 += ' <i class="bi bi-clock ms-1"></i>';
} else if (status === 'failed') {
timeEl.innerHTML += ' <i class="bi bi-exclamation-circle text-danger ms-1"></i>';
}
}
}
// ==================== Утилиты ====================
/**
* Форматирует время
* @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;