Corp/backend/static/js/renderers/messageRendererV2.js
Игорь Надеин d3a876f1cb refactor: Remove all autoscroll except prepend physics correction
BREAKTHROUGH: Discovered append doesn't need autoscroll!

Changes:
- ✅ KEPT: scrollTop correction after prepend (physics, not autoscroll)
  Prevents infinite history loading by compensating scrollHeight growth
- ❌ REMOVED: scrollToBottom() - now empty stub
- ❌ REMOVED: scrollToMessage() - now empty stub
- ❌ REMOVED: Initial scroll on chat open
- ❌ REMOVED: Scroll after loadAround

Philosophy shift:
- Old: We control user's scroll position
- New: IntersectionObserver + physics = natural behavior

Results:
- Newer messages load perfectly WITHOUT autoscroll
- Older messages need ONLY physics correction
- User controls their own scroll position
- Natural infinite scroll experience
2026-01-13 14:35:49 +03:00

793 lines
31 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 = {
'&': '&',
'<': '&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 содержимое
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 = `
<div class="message-bubble ${isOwn ? 'bg-primary text-white' : 'bg-light'} rounded-3 p-2 position-relative">
${!isOwn ? `
<div class="message-author mb-1">
<a href="${authorUrl}" class="text-decoration-none fw-bold ${isOwn ? 'text-white' : 'text-primary'}">
${authorName}
</a>
</div>
` : ''}
<div class="message-content">${content}${msg.is_edited ? '<small class="edited-indicator text-muted ms-1">(ред.)</small>' : ''}</div>
${msg.attachments && msg.attachments.length > 0 ? `
<div class="message-attachments mt-2">
${msg.attachments.map(att => this._renderAttachment(att, isOwn)).join('')}
</div>
` : ''}
<div class="message-time small ${isOwn ? 'text-white-50' : 'text-muted'} mt-1">
${time}
${msg.status === 'sending' ? '<i class="bi bi-clock ms-1"></i>' : ''}
${msg.status === 'failed' ? '<i class="bi bi-exclamation-circle text-danger ms-1"></i>' : ''}
</div>
${msg.reactions_summary && Object.keys(msg.reactions_summary).length > 0 ? `
<div class="message-reactions mt-2">
${this._renderReactions(msg.reactions_summary)}
</div>
` : ''}
</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 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 `
<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;