diff --git a/backend/static/js/components/messageRenderer.legacy.js b/backend/static/js/components/messageRenderer.legacy.js
deleted file mode 100644
index de8ceef3..00000000
--- a/backend/static/js/components/messageRenderer.legacy.js
+++ /dev/null
@@ -1,346 +0,0 @@
-/**
- * ⚠️ DEPRECATED - LEGACY CODE ⚠️
- * Используйте messageRendererV2.js вместо этого файла
- * Этот файл сохранен только для справки
- *
- * Message Renderer
- * Рендерит сообщения в DOM из JSON данных
- */
-
-export class MessageRenderer {
- constructor(config = {}) {
- this.containerId = config.containerId || 'chatScroll';
- this.currentUserId = config.currentUserId || config.meId; // Поддержка обоих параметров
- this.currentUserAvatar = config.currentUserAvatar || '';
- this.profileUrl = config.profileUrl || '/employees/profile/';
- this.detailUrlTemplate = config.detailUrlTemplate || '/employees/detail/0/';
- }
-
- /**
- * Рендерит массив сообщений (с разделителями дней)
- */
- renderMessages(messages) {
- const container = document.getElementById(this.containerId);
- if (!container) {
- console.error('[MessageRenderer] Container not found:', this.containerId);
- return;
- }
-
- // ОПТИМИЗАЦИЯ: Используем DocumentFragment для батчевой вставки
- const fragment = document.createDocumentFragment();
- let lastDay = null;
-
- messages.forEach(msg => {
- // Проверяем на дубликаты
- const existingMessage = container.querySelector(`[data-message-id="${msg.id}"]`);
- if (existingMessage) {
- console.log('[MessageRenderer] Message already exists, skipping:', msg.id);
- return;
- }
-
- // Добавляем разделитель дня если нужно
- const msgDate = new Date(msg.created_ts);
- const msgDay = this.formatDay(msgDate);
-
- if (msgDay !== lastDay) {
- const dividerEl = document.createElement('div');
- dividerEl.className = 'day-divider text-center small text-muted my-3';
- dividerEl.innerHTML = `${msgDay}`;
- fragment.appendChild(dividerEl);
- lastDay = msgDay;
- }
-
- // Создаем элемент сообщения и добавляем во fragment
- const isOwn = msg.author_id === this.currentUserId;
- const messageEl = document.createElement('div');
- messageEl.className = `d-flex mb-3 msg ${isOwn ? 'justify-content-end' : 'justify-content-start'}`;
- 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));
- messageEl.setAttribute('data-edited-at', String(msg.edited_at || ''));
- messageEl.setAttribute('data-reactions', JSON.stringify(msg.reactions_summary || {}));
- messageEl.innerHTML = this.buildMessageInnerHtml(msg, isOwn);
-
- fragment.appendChild(messageEl);
- });
-
- // Одна вставка всех элементов - минимизируем reflow
- container.appendChild(fragment);
- }
-
- /**
- * Добавляет разделитель дня
- */
- addDayDivider(dayText, container = null) {
- if (!container) {
- container = document.getElementById(this.containerId);
- }
- if (!container) return;
-
- const dividerHtml = `
-
- ${dayText}
-
- `;
- container.insertAdjacentHTML('beforeend', dividerHtml);
- }
-
- /**
- * Форматирует дату в текст дня
- */
- formatDay(date) {
- const today = new Date();
- const yesterday = new Date(today);
- yesterday.setDate(yesterday.getDate() - 1);
-
- const isToday = date.toDateString() === today.toDateString();
- const isYesterday = date.toDateString() === yesterday.toDateString();
-
- if (isToday) return 'Сегодня';
- if (isYesterday) return 'Вчера';
-
- const options = { day: 'numeric', month: 'long', year: 'numeric' };
- return date.toLocaleDateString('ru-RU', options);
- }
-
- /**
- * Рендерит одно сообщение
- */
- renderMessage(msg, container = null) {
- if (!container) {
- container = document.getElementById(this.containerId);
- }
-
- // Проверяем, нет ли уже сообщения с таким ID (предотвращаем дубликаты)
- const existingMessage = container.querySelector(`[data-message-id="${msg.id}"]`);
- if (existingMessage) {
- console.log('[MessageRenderer] Message already exists, skipping:', msg.id);
- return;
- }
-
- // 🔥 КРИТИЧНО: Проверяем нужен ли day-divider перед новым сообщением
- const msgDate = new Date(msg.created_ts);
- const msgDay = this.formatDay(msgDate);
-
- // Находим последний day-divider в контейнере
- const lastDivider = container.querySelector('.day-divider:last-of-type');
- const lastDividerText = lastDivider ? lastDivider.textContent.trim() : null;
-
- // Если дня еще нет или день изменился - создаем divider
- if (!lastDividerText || lastDividerText !== msgDay) {
- const dividerEl = document.createElement('div');
- dividerEl.className = 'day-divider text-center small text-muted my-3';
- dividerEl.innerHTML = `${msgDay}`;
- container.appendChild(dividerEl);
- }
-
- const isOwn = msg.author_id === this.currentUserId;
- const messageHtml = this.buildMessageHtml(msg, isOwn);
-
- // Добавляем в конец контейнера
- container.insertAdjacentHTML('beforeend', messageHtml);
- }
-
- /**
- * Вставляет сообщение в начало (для истории)
- */
- prependMessage(msg) {
- const container = document.getElementById(this.containerId);
- if (!container) return;
-
- // Проверяем, нет ли уже сообщения с таким ID (предотвращаем дубликаты)
- const existingMessage = container.querySelector(`[data-message-id="${msg.id}"]`);
- if (existingMessage) {
- console.log('[MessageRenderer] Message already exists (prepend), skipping:', msg.id);
- return;
- }
-
- const isOwn = msg.author_id === this.currentUserId;
- const messageHtml = this.buildMessageHtml(msg, isOwn);
-
- // Находим первое сообщение (не day-divider, не history-loader)
- const firstMsg = container.querySelector('.msg');
- if (firstMsg) {
- firstMsg.insertAdjacentHTML('beforebegin', messageHtml);
- } else {
- container.insertAdjacentHTML('beforeend', messageHtml);
- }
- }
-
- /**
- * Создаёт DOM элемент сообщения без вставки в контейнер
- * Используется для pending сообщений, где нужен прямой доступ к элементу
- * @param {Object} msg - Объект сообщения
- * @param {boolean} isPending - Является ли сообщение pending
- * @returns {HTMLElement} DOM элемент сообщения
- */
- createMessageElement(msg, isPending = false) {
- const isOwn = msg.author_id === this.currentUserId;
-
- const wrap = document.createElement('div');
- wrap.className = `d-flex mb-3 msg ${isOwn ? 'justify-content-end' : 'justify-content-start'}`;
-
- if (isPending) {
- wrap.classList.add('message-pending');
- }
-
- if (msg.id != null) {
- wrap.setAttribute('data-id', String(msg.id));
- wrap.setAttribute('data-message-id', String(msg.id));
- const reactionsData = msg.reactions_summary || {};
- wrap.setAttribute('data-reactions', JSON.stringify(reactionsData));
- }
-
- const ts = msg.created_ts || Date.now();
- wrap.setAttribute('data-ts', String(ts));
- wrap.setAttribute('data-author-id', String(msg.author_id || ''));
- wrap.setAttribute('data-is-edited', String(msg.is_edited || false));
- wrap.setAttribute('data-edited-at', String(msg.edited_at || ''));
-
- // Используем buildMessageInnerHtml для единообразного рендеринга
- const htmlContent = this.buildMessageInnerHtml(msg, isOwn);
-
- // Добавляем статус отправки для pending сообщений
- if (isPending) {
- const tempDiv = document.createElement('div');
- tempDiv.innerHTML = htmlContent;
-
- const bubble = tempDiv.querySelector('.bubble');
- if (bubble) {
- const pendingStatus = document.createElement('div');
- pendingStatus.className = 'message-status small text-secondary mt-2';
- pendingStatus.innerHTML = 'Отправляем…';
- bubble.appendChild(pendingStatus);
- }
-
- wrap.innerHTML = tempDiv.innerHTML;
- } else {
- wrap.innerHTML = htmlContent;
- }
-
- return wrap;
- }
-
- /**
- * Строит HTML одного сообщения
- */
- buildMessageHtml(msg, isOwn) {
- return `
-
- ${this.buildMessageInnerHtml(msg, isOwn)}
-
`;
- }
-
- /**
- * Строит внутренний HTML сообщения (без внешней обёртки .msg)
- * Используется в chatMessageTemplates.js для создания DOM элементов
- */
- buildMessageInnerHtml(msg, isOwn) {
- const authorUrl = isOwn
- ? this.profileUrl
- : this.detailUrlTemplate.replace('/0/', `/${msg.author_id}/`);
-
- const authorName = isOwn
- ? 'Вы'
- : (msg.author_name || 'Пользователь');
-
- const avatarHtml = isOwn ? '' : `${msg.avatar ? `
` : ''}`;
-
- const forwardedHtml = msg.is_forwarded && msg.forwarded_from ? `Переслано от ${msg.forwarded_from.author_name || 'Пользователя'}${msg.forwarded_from.chat_name ? ` из «${msg.forwarded_from.chat_name}»` : ''}
${msg.forwarded_from.created_at ? `
${msg.forwarded_from.created_at}
` : ''}
` : '';
-
- const replyHtml = msg.reply_to ? `${msg.reply_to.author_name || 'Пользователь'}
${this.escapeHtml(msg.reply_to.content || '[файл]')}
` : '';
-
- const attachmentsHtml = msg.attachments?.length ?
- msg.attachments.map(att => this.buildAttachmentHtml(att)).join('') : '';
-
- // Голосование (опрос)
- const pollHtml = msg.poll ? this.buildPollHtml(msg.poll) : '';
-
- // Индикатор редактирования
- const editedIndicator = msg.is_edited ?
- `(изменено)` : '';
-
- // Реакции теперь рендерятся через message-reactions-wrapper ВНЕ bubble
- // Старая система buildReactionsHtml() больше не используется
-
- const timeHtml = this.formatTime(msg.created_ts);
-
- // Аватар справа для своих сообщений
- const rightAvatarHtml = isOwn ? `${this.currentUserAvatar ? `
` : ''}` : '';
-
- return `${avatarHtml}${authorName} ·
${editedIndicator}
${forwardedHtml}${replyHtml}${msg.content ? this.escapeHtml(msg.content) : ''}${attachmentsHtml}${pollHtml}
${rightAvatarHtml}`;
- }
-
- /**
- * Строит HTML вложения
- */
- buildAttachmentHtml(attachment) {
- const fileName = attachment.file_name || attachment.original_filename || 'файл';
- const isImage = attachment.file_url?.match(/\.(jpg|jpeg|png|gif|webp)$/i);
-
- if (isImage) {
- return `
`;
- }
-
- return ``;
- }
-
- /**
- * Строит HTML для голосования (опроса)
- */
- buildPollHtml(poll) {
- if (!poll || !poll.id) return '';
-
- const totalVotes = poll.total_voters || 0;
- const isClosed = poll.is_closed || false;
-
- // Базовая структура - детали будут добавлены через ChatPoll.js
- // Убираем лишние margin-классы: mt-2 у poll-widget, mb-3 у poll-question, mt-3 у poll-footer
- return ``;
- }
-
- /**
- * Форматирует дату/время
- */
- 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}`;
- }
-
- formatDate(timestamp) {
- const date = new Date(timestamp);
- const day = String(date.getDate()).padStart(2, '0');
- const month = String(date.getMonth() + 1).padStart(2, '0');
- const year = date.getFullYear();
- const hours = String(date.getHours()).padStart(2, '0');
- const minutes = String(date.getMinutes()).padStart(2, '0');
- return `${day}.${month}.${year} ${hours}:${minutes}`;
- }
-
- /**
- * Экранирует HTML
- */
- escapeHtml(text) {
- const div = document.createElement('div');
- div.textContent = text;
- return div.innerHTML;
- }
-}
-
-/**
- * Фабричная функция для совместимости
- */
-export function createMessageRenderer(config) {
- return new MessageRenderer(config);
-}
diff --git a/backend/static/js/loaders/messageLoader.legacy.js b/backend/static/js/loaders/messageLoader.legacy.js
deleted file mode 100644
index 0357ce9c..00000000
--- a/backend/static/js/loaders/messageLoader.legacy.js
+++ /dev/null
@@ -1,433 +0,0 @@
-/**
- * @fileoverview MessageLoader - единый загрузчик сообщений
- * @module loaders/messageLoader
- *
- * Отвечает за ВСЕ операции загрузки сообщений:
- * - Начальная загрузка при открытии чата
- * - История при scroll up
- * - Новые сообщения через WebSocket
- * - Оптимистичная отправка
- *
- * Все данные сохраняются в MessageStore.
- */
-
-/**
- * MessageLoader - единый загрузчик сообщений
- */
-export class MessageLoader {
- /**
- * @param {Object} options - Опции конфигурации
- * @param {MessageStore} options.store - Экземпляр MessageStore
- * @param {Object} options.wsConnection - WebSocket соединение (userWebSocket)
- * @param {number} options.currentUserId - ID текущего пользователя
- */
- constructor(options) {
- if (!options.store) {
- throw new Error('[MessageLoader] MessageStore is required');
- }
-
- this.store = options.store;
- this.ws = options.wsConnection;
- this.currentUserId = options.currentUserId;
- this.currentChatId = null; // Текущий открытый чат
-
- /** @type {Map} */
- this.loadingState = new Map();
-
- /** @type {Map} */
- this.chatState = new Map();
-
- console.log('[MessageLoader] Initialized');
- }
-
- // ==================== Загрузка начальных сообщений ====================
-
- /**
- * Загружает начальные сообщения для чата
- * НОВАЯ АРХИТЕКТУРА: Загружает ВОКРУГ last_read_message_id
- * @param {number} chatId - ID чата
- * @param {Object} options - Опции
- * @param {number} [options.limit=50] - Количество сообщений (по умолчанию 50)
- * @param {number} [options.lastReadMessageId] - ID последнего прочитанного (для around)
- * @returns {Promise