From 697f8c6d37aa38800aa09503e14d9ce1aa46ee31 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=98=D0=B3=D0=BE=D1=80=D1=8C=20=D0=9D=D0=B0=D0=B4=D0=B5?= =?UTF-8?q?=D0=B8=D0=BD?= Date: Tue, 13 Jan 2026 10:14:11 +0300 Subject: [PATCH] =?UTF-8?q?refactor:=20=D1=83=D0=B4=D0=B0=D0=BB=D0=B5?= =?UTF-8?q?=D0=BD=20legacy=20=D0=BA=D0=BE=D0=B4,=20chatDetailV2=20=D0=BF?= =?UTF-8?q?=D0=B5=D1=80=D0=B5=D0=B8=D0=BC=D0=B5=D0=BD=D0=BE=D0=B2=D0=B0?= =?UTF-8?q?=D0=BD=20=D0=B2=20chatDetail?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit УДАЛЕНО (57KB, 1306 строк): ✅ scrollManager.legacy.js (23KB) - заменен на ScrollManagerV2 ✅ messageLoader.legacy.js (16KB) - заменен на MessageLoaderV2 ✅ messageRenderer.legacy.js (18KB) - заменен на MessageRendererV2 ✅ chatDetail.js (старая версия) - имел deprecated обертки ✅ chatDetailTest.js - тестовый файл ПЕРЕИМЕНОВАНО: ✅ chatDetailV2.js → chatDetail.js (стал основным) ПРЕИМУЩЕСТВА: - Прямой импорт ChatControllerV2 - Нет deprecated оберток - Поддержка lastReadTimestamp fallback - Правильная загрузка вокруг lastRead - Минус 57KB мертвого кода --- .../js/components/messageRenderer.legacy.js | 346 ------------ .../static/js/loaders/messageLoader.legacy.js | 433 -------------- .../js/managers/scrollManager.legacy.js | 527 ------------------ backend/static/js/pages/chatDetail.js | 477 ++++++++-------- backend/static/js/pages/chatDetailTest.js | 29 - backend/static/js/pages/chatDetailV2.js | 244 -------- .../templates/communications/chat_detail.html | 4 +- 7 files changed, 230 insertions(+), 1830 deletions(-) delete mode 100644 backend/static/js/components/messageRenderer.legacy.js delete mode 100644 backend/static/js/loaders/messageLoader.legacy.js delete mode 100644 backend/static/js/managers/scrollManager.legacy.js delete mode 100644 backend/static/js/pages/chatDetailTest.js delete mode 100644 backend/static/js/pages/chatDetailV2.js 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 `
${this.escapeHtml(fileName)}
`; - } - - return `
${this.escapeHtml(fileName)}
`; - } - - /** - * Строит 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 `
${this.escapeHtml(poll.question)}
${poll.options?.map(option => `
`).join('') || ''}
`; - } - - /** - * Форматирует дату/время - */ - 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} Результат загрузки - */ - async loadInitialMessages(chatId, options = {}) { - const state = this._getLoadingState(chatId); - - if (state.initial) { - return { messages: [], anchorId: null }; - } - - try { - this.currentChatId = chatId; - state.initial = true; - state.status = 'loading'; - - const limit = options.limit || 50; - const lastReadId = options.lastReadMessageId; - - // Используем /around/ если есть lastReadId (и он > 0) - let url; - if (lastReadId && lastReadId > 0) { - url = new URL(`/api/v1/communications/chats/${chatId}/messages/around/`, window.location.origin); - url.searchParams.set('around_id', String(lastReadId)); - url.searchParams.set('limit', String(limit)); - } else { - // Fallback: загружаем последние сообщения - url = new URL(`/api/v1/communications/chats/${chatId}/messages/`, window.location.origin); - url.searchParams.set('limit', String(limit)); - } - - const response = await fetch(url.toString(), { - headers: { - 'X-Requested-With': 'XMLHttpRequest' - }, - credentials: 'same-origin' - }); - - if (!response.ok) { - throw new Error(`HTTP ${response.status}: ${response.statusText}`); - } - - const data = await response.json(); - const messages = data.messages || data.results || []; - - console.log('[MessageLoader] Loaded', messages.length, 'initial messages'); - - // Добавляем chat_id к каждому сообщению (API не возвращает его в ответе) - messages.forEach(msg => { - if (!msg.chat_id) { - msg.chat_id = chatId; - } - }); - - // Сохраняем в Store - this.store.addMessages(messages); - - // Обновляем состояние чата - // Важно: oldestId берем из Store, а не из messages[0], тк Store гарантирует правильную сортировку - const oldestMessage = this.store.getOldestMessage(chatId); - this._updateChatState(chatId, { - hasMore: data.has_more !== false || data.has_more_before !== false, - oldestId: oldestMessage ? oldestMessage.id : null - }); - - state.status = 'loaded'; - - return { - messages, - anchorId: data.anchor_id || null, - anchorIndex: data.anchor_index || null, - hasMoreBefore: data.has_more_before || false, - hasMoreAfter: data.has_more_after || false - }; - - } catch (error) { - console.error('[MessageLoader] Failed to load initial messages:', error); - state.status = 'error'; - throw error; - } finally { - state.initial = false; - } - } - - // ==================== Загрузка истории ==================== - - /** - * Загружает историю сообщений (при scroll up) - * @param {number} chatId - ID чата - * @param {Object} options - Опции - * @param {number|string} [options.beforeId] - Загрузить до этого сообщения - * @param {number} [options.limit=20] - Количество сообщений - * @returns {Promise} Массив сообщений - */ - async loadHistoryBefore(chatId, options = {}) { - const state = this._getLoadingState(chatId); - const chatState = this._getChatState(chatId); - - if (state.history) { - return []; - } - - if (!chatState.hasMore) { - return []; - } - - try { - state.history = true; - - const beforeId = options.beforeId || chatState.oldestId; - if (!beforeId) { - console.warn('[MessageLoader] No beforeId for history loading'); - return []; - } - - const url = new URL(`/api/v1/communications/chats/${chatId}/messages/`, window.location.origin); - url.searchParams.set('before_id', String(beforeId)); - url.searchParams.set('limit', String(options.limit || 50)); // Telegram-стиль: больше сообщений - - const response = await fetch(url.toString(), { - headers: { - 'X-Requested-With': 'XMLHttpRequest' - }, - credentials: 'same-origin' - }); - - if (!response.ok) { - throw new Error(`HTTP ${response.status}: ${response.statusText}`); - } - - const data = await response.json(); - const messages = data.messages || data.results || []; - - // Добавляем chat_id к каждому сообщению - messages.forEach(msg => { - if (!msg.chat_id) { - msg.chat_id = chatId; - } - }); - - // Сохраняем в Store и получаем количество РЕАЛЬНО добавленных - const addedCount = messages.length > 0 ? this.store.addMessages(messages) : 0; - - // Обновляем состояние чата - if (addedCount > 0) { - // Обновляем oldestId - берем из Store - const oldestMessage = this.store.getOldestMessage(chatId); - this._updateChatState(chatId, { - oldestId: oldestMessage ? oldestMessage.id : null, - hasMore: data.has_more !== false - }); - } else if (messages.length > 0 && addedCount === 0) { - // API вернул сообщения, но все оказались дубликатами - this._updateChatState(chatId, { hasMore: false }); - } else { - // Пустой ответ от API - больше нет истории - this._updateChatState(chatId, { hasMore: false }); - } - - // Возвращаем только реально добавленные сообщения - return messages.filter(m => this.store.hasMessage(m.id)); - - } catch (error) { - console.error('[MessageLoader] Failed to load history:', error); - throw error; - } finally { - state.history = false; - } - } - - // ==================== WebSocket обработчики ==================== - - /** - * Обрабатывает новое сообщение из WebSocket - * @param {Object} message - Сообщение с сервера - */ - handleNewMessage(message) { - if (!message || !message.id) { - console.error('[MessageLoader] Invalid message received:', message); - return; - } - - // Убеждаемся что есть chat_id - if (!message.chat_id && this.currentChatId) { - message.chat_id = this.currentChatId; - } - - // Проверяем - это подтверждение оптимистичного сообщения? - if (message.temp_id) { - const optimistic = this.store.optimisticMessages.get(message.temp_id); - if (optimistic) { - this.store.confirmOptimisticMessage(message.temp_id, message); - return; - } - } - - // Обычное новое сообщение - this.store.addMessage(message); - } - - /** - * Обрабатывает редактирование сообщения из WebSocket - * @param {Object} data - Данные редактирования - */ - handleMessageEdited(data) { - const { message } = data; - if (!message || !message.id) { - console.error('[MessageLoader] Invalid edit data:', data); - return; - } - - this.store.updateMessage(message.id, message); - } - - /** - * Обрабатывает удаление сообщения из WebSocket - * @param {Object} data - Данные удаления - */ - handleMessageRemoved(data) { - const messageId = data.message_id || data.messageId; - if (!messageId) { - console.error('[MessageLoader] Invalid remove data:', data); - return; - } - - this.store.removeMessage(messageId); - } - - /** - * Обрабатывает добавление реакции - * @param {Object} data - Данные реакции - */ - handleReactionAdded(data) { - const { message_id, reactions_summary } = data; - if (!message_id) return; - - const message = this.store.getMessage(message_id); - if (message) { - this.store.updateMessage(message_id, { reactions_summary }); - } - } - - /** - * Обрабатывает удаление реакции - * @param {Object} data - Данные реакции - */ - handleReactionRemoved(data) { - const { message_id, reactions_summary } = data; - if (!message_id) return; - - const message = this.store.getMessage(message_id); - if (message) { - this.store.updateMessage(message_id, { reactions_summary }); - } - } - - // ==================== Отправка сообщений ==================== - - /** - * Отправляет сообщение оптимистично (показываем до подтверждения сервера) - * @param {number} chatId - ID чата - * @param {string} content - Содержимое сообщения - * @param {Object} options - Дополнительные опции - * @returns {string} Временный ID сообщения - */ - sendMessageOptimistically(chatId, content, options = {}) { - const tempId = `temp_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; - - const optimisticMessage = { - id: tempId, - temp_id: tempId, - chat_id: chatId, - author_id: this.currentUserId, - content, - created_ts: Date.now(), - status: 'sending', - is_edited: false, - reactions_summary: {}, - ...options - }; - - console.log('[MessageLoader] Sending optimistic message:', tempId); - - // Добавляем в Store как оптимистичное - this.store.addMessage(optimisticMessage, true); - - // Отправляем через WebSocket (если доступен) - if (this.ws && this.ws.send) { - try { - this.ws.send({ - type: 'send_message', - chat_id: chatId, - content, - temp_id: tempId, - ...options - }); - } catch (error) { - console.error('[MessageLoader] Failed to send via WS:', error); - this.store.failOptimisticMessage(tempId); - } - } else { - console.warn('[MessageLoader] WebSocket not available, message not sent'); - this.store.failOptimisticMessage(tempId); - } - - return tempId; - } - - // ==================== Утилиты ==================== - - /** - * Проверяет можно ли загрузить больше истории - * @param {number} chatId - ID чата - * @returns {boolean} - */ - hasMoreHistory(chatId) { - const chatState = this._getChatState(chatId); - return chatState.hasMore; - } - - /** - * Проверяет загружается ли что-то для чата - * @param {number} chatId - ID чата - * @returns {boolean} - */ - isLoading(chatId) { - const state = this._getLoadingState(chatId); - return state.initial || state.history; - } - - /** - * Получает статус загрузки для чата - * @param {number} chatId - ID чата - * @returns {Object} - */ - getLoadingStatus(chatId) { - return { - ...this._getLoadingState(chatId), - ...this._getChatState(chatId) - }; - } - - // ==================== Приватные методы ==================== - - /** - * Получает состояние загрузки для чата - * @private - */ - _getLoadingState(chatId) { - if (!this.loadingState.has(chatId)) { - this.loadingState.set(chatId, { - initial: false, - history: false, - status: 'idle' - }); - } - return this.loadingState.get(chatId); - } - - /** - * Получает состояние чата - * @private - */ - _getChatState(chatId) { - if (!this.chatState.has(chatId)) { - this.chatState.set(chatId, { - hasMore: true, - oldestId: null - }); - } - return this.chatState.get(chatId); - } - - /** - * Обновляет состояние чата - * @private - */ - _updateChatState(chatId, updates) { - const state = this._getChatState(chatId); - Object.assign(state, updates); - } -} - -export default MessageLoader; diff --git a/backend/static/js/managers/scrollManager.legacy.js b/backend/static/js/managers/scrollManager.legacy.js deleted file mode 100644 index 26ce71ca..00000000 --- a/backend/static/js/managers/scrollManager.legacy.js +++ /dev/null @@ -1,527 +0,0 @@ -/** - * ⚠️ DEPRECATED - LEGACY CODE ⚠️ - * Используйте scrollManagerV2.js вместо этого файла - * Этот файл сохранен только для справки - * - * @fileoverview ScrollManager - централизованное управление прокруткой - * @module managers/scrollManager - * - * Отвечает за ВСЮ логику скролла в чате: - * - Автоматическая загрузка истории при scroll up - * - Умный scroll to bottom без прыжков - * - Определение "внизу ли пользователь" - * - Сохранение позиции при загрузке истории - */ - -/** - * ScrollManager - управление прокруткой чата - */ -export class ScrollManager { - /** - * @param {Object} options - Опции конфигурации - * @param {HTMLElement} options.scrollElement - Контейнер для скролла - * @param {MessageLoader} options.messageLoader - Загрузчик сообщений - * @param {MessageRenderer} options.messageRenderer - Рендерер сообщений - * @param {MessageStore} options.messageStore - Store сообщений - * @param {number} options.chatId - ID текущего чата - */ - constructor(options) { - if (!options.scrollElement) { - throw new Error('[ScrollManager] scrollElement is required'); - } - if (!options.messageLoader) { - throw new Error('[ScrollManager] messageLoader is required'); - } - if (!options.messageRenderer) { - throw new Error('[ScrollManager] messageRenderer is required'); - } - - this.scrollEl = options.scrollElement; - this.loader = options.messageLoader; - this.renderer = options.messageRenderer; - this.store = options.messageStore; - this.chatId = options.chatId; - - /** @type {IntersectionObserver|null} */ - this.historyObserver = null; - - /** @type {boolean} */ - this.isLoadingHistory = false; - - /** @type {number} Порог в пикселях для определения "внизу" */ - this.bottomThreshold = 100; - - console.log('[ScrollManager] Initialized for chat:', this.chatId); - } - - // ==================== Инициализация ==================== - - /** - * Инициализирует ScrollManager - */ - async init() { - // Даём время на завершение рендера всех сообщений - await new Promise(resolve => setTimeout(resolve, 100)); - - this.setupIntersectionObserver(); - this.setupScrollListener(); - console.log('[ScrollManager] Initialized'); - } - - /** - * Останавливает ScrollManager и очищает ресурсы - */ - destroy() { - if (this.historyObserver) { - this.historyObserver.disconnect(); - this.historyObserver = null; - } - console.log('[ScrollManager] Destroyed'); - } - - // ==================== IntersectionObserver для автозагрузки ==================== - - /** - * Настраивает IntersectionObserver для автоматической загрузки истории - */ - setupIntersectionObserver() { - console.log('[ScrollManager] ========================================'); - console.log('[ScrollManager] setupIntersectionObserver called'); - - // Отключаем старый observer если есть - if (this.historyObserver) { - console.log('[ScrollManager] Disconnecting old observer'); - this.historyObserver.disconnect(); - this.historyObserver = null; - } - - // Находим первое сообщение (просто .msg - найдет первый с этим классом) - const firstMessage = this.scrollEl.querySelector('.msg'); - - console.log('[ScrollManager] First message element:', !!firstMessage); - console.log('[ScrollManager] Total messages in DOM:', this.scrollEl.querySelectorAll('.msg').length); - - // Логируем реальные классы первых 3 элементов в контейнере - const allChildren = this.scrollEl.children; - console.log('[ScrollManager] First 3 elements in container:'); - for (let i = 0; i < Math.min(3, allChildren.length); i++) { - const el = allChildren[i]; - console.log(` [${i}]:`, { - tag: el.tagName, - classes: el.className, - messageId: el.dataset?.messageId - }); - } - - if (!firstMessage) { - console.warn('[ScrollManager] ❌ No messages yet, observer NOT set'); - console.log('[ScrollManager] ========================================'); - return; - } - - console.log('[ScrollManager] First message ID:', firstMessage.dataset?.messageId); - - // Создаем observer - this.historyObserver = new IntersectionObserver( - entries => { - entries.forEach(entry => { - console.log('[ScrollManager] IntersectionObserver triggered:', { - isIntersecting: entry.isIntersecting, - isLoadingHistory: this.isLoadingHistory, - target: entry.target.dataset?.messageId - }); - - if (entry.isIntersecting && !this.isLoadingHistory) { - console.log('[ScrollManager] ✅ First message visible, loading history...'); - this.loadMoreHistory(); - } - }); - }, - { - root: this.scrollEl, - threshold: 0.1, - rootMargin: '300px 0px 0px 0px' // Telegram-стиль: загружаем намного раньше - } - ); - - this.historyObserver.observe(firstMessage); - console.log('[ScrollManager] ✅ IntersectionObserver attached to first message'); - console.log('[ScrollManager] ========================================'); - } - - /** - * Обновляет observer на новое первое сообщение - */ - updateObserverTarget() { - console.log('[ScrollManager] ========================================'); - console.log('[ScrollManager] updateObserverTarget called'); - - if (!this.historyObserver) { - console.warn('[ScrollManager] No observer exists'); - console.log('[ScrollManager] ========================================'); - return; - } - - // Отключаем от всех - this.historyObserver.disconnect(); - - // Находим РЕАЛЬНОЕ первое сообщение с data-message-id - // Важно: после добавления истории в начало, ПЕРВОЕ сообщение в DOM - это самое старое - const firstMessage = this.scrollEl.querySelector('.msg[data-message-id]'); - - console.log('[ScrollManager] Found first message:', !!firstMessage); - if (firstMessage) { - console.log('[ScrollManager] First message ID:', firstMessage.dataset.messageId); - this.historyObserver.observe(firstMessage); - console.log('[ScrollManager] ✅ Observer updated to new first message'); - } else { - console.warn('[ScrollManager] ❌ No first message found in DOM'); - } - console.log('[ScrollManager] ========================================'); - } - - // ==================== Загрузка истории ==================== - - /** - * Загружает больше истории при scroll up - */ - async loadMoreHistory() { - console.log('[ScrollManager] ========================================'); - console.log('[ScrollManager] loadMoreHistory called'); - console.log('[ScrollManager] isLoadingHistory:', this.isLoadingHistory); - - if (this.isLoadingHistory) { - console.log('[ScrollManager] ❌ Already loading history'); - console.log('[ScrollManager] ========================================'); - return; - } - - const hasMore = this.loader.hasMoreHistory(this.chatId); - console.log('[ScrollManager] hasMoreHistory:', hasMore); - - if (!hasMore) { - console.log('[ScrollManager] ❌ No more history available'); - console.log('[ScrollManager] ========================================'); - return; - } - - try { - this.isLoadingHistory = true; - - // ============================================ - // ПРАВИЛЬНЫЙ подход из Telegram Web: - // 1. Сохраняем scrollHeight и scrollTop ДО добавления элементов - // 2. Prepend элементы в начало (НЕ полный re-render!) - // 3. Вычисляем разницу scrollHeight - // 4. Корректируем scrollTop на эту разницу - // ============================================ - - // Отключаем smooth scroll чтобы изменения были мгновенными - const originalScrollBehavior = this.scrollEl.style.scrollBehavior; - this.scrollEl.style.scrollBehavior = 'auto'; - - const oldScrollHeight = this.scrollEl.scrollHeight; - const oldScrollTop = this.scrollEl.scrollTop; - - console.log('[ScrollManager] ✅ Loading history...', { - oldScrollHeight, - oldScrollTop - }); - - // Загружаем историю через Loader (возвращает уже загруженные сообщения) - const messages = await this.loader.loadHistoryBefore(this.chatId); - - // Проверяем есть ли еще история ПОСЛЕ загрузки - const stillHasMore = this.loader.hasMoreHistory(this.chatId); - - if (messages.length === 0 || !stillHasMore) { - console.log('[ScrollManager] ⚠️ No more unique history to load'); - console.log('[ScrollManager] ========================================'); - this.isLoadingHistory = false; - return; - } - - console.log('[ScrollManager] ✅ History loaded:', messages.length, 'messages'); - - // КЛЮЧЕВОЙ МОМЕНТ: НЕ делаем полный render! - // Создаем fragment с новыми сообщениями - const fragment = this.renderer.prependMessages(messages, this.chatId); - - // Если fragment пустой (все сообщения уже отрендерены), выходим - if (!fragment.hasChildNodes()) { - console.log('[ScrollManager] ⚠️ All messages already rendered'); - this.scrollEl.style.scrollBehavior = originalScrollBehavior; - this.isLoadingHistory = false; - return; - } - - // ВАЖНО: Проверяем и удаляем потенциальные дубликаты divider - // ПЕРЕД prepend, чтобы избежать визуальных скачков - const firstFragmentChild = fragment.firstElementChild; - if (firstFragmentChild && firstFragmentChild.classList.contains('day-divider')) { - const firstContainerChild = this.scrollEl.firstElementChild; - if (firstContainerChild && firstContainerChild.classList.contains('day-divider')) { - const fragmentDividerText = firstFragmentChild.textContent.trim(); - const containerDividerText = firstContainerChild.textContent.trim(); - - // Если dividers одинаковые, удаляем из fragment - if (fragmentDividerText === containerDividerText) { - fragment.removeChild(firstFragmentChild); - console.log('[ScrollManager] Removed duplicate divider before prepend:', fragmentDividerText); - } - } - } - - // Prepend fragment в НАЧАЛО контейнера (как в Telegram) - this.scrollEl.prepend(fragment); - - // КЛЮЧЕВОЕ ОТЛИЧИЕ Telegram: ДВОЙНОЙ RAF для стабильности! - // Первый RAF - браузер обновляет layout - // Второй RAF - браузер гарантированно применил все изменения - await new Promise(resolve => { - requestAnimationFrame(() => { - requestAnimationFrame(() => { - // КЛЮЧЕВОЙ МОМЕНТ: вычисляем разницу в высоте - const newScrollHeight = this.scrollEl.scrollHeight; - const heightDifference = newScrollHeight - oldScrollHeight; - - // Корректируем scrollTop на разницу высоты - // Это сохраняет визуальную позицию пользователя ТОЧНО там же - // БЕЗ КАКИХ-ЛИБО ПРЫЖКОВ! - this.scrollEl.scrollTop = oldScrollTop + heightDifference; - - console.log('[ScrollManager] Scroll restored using height difference:', { - oldScrollHeight, - newScrollHeight, - heightDifference, - oldScrollTop, - newScrollTop: this.scrollEl.scrollTop - }); - - // Обновляем observer на новое первое сообщение - this.updateObserverTarget(); - - // Восстанавливаем scroll-behavior - this.scrollEl.style.scrollBehavior = originalScrollBehavior; - - resolve(); - }); - }); - }); - - } catch (error) { - console.error('[ScrollManager] Failed to load history:', error); - } finally { - this.isLoadingHistory = false; - } - } - - // ==================== Scroll управление ==================== - - /** - * Настраивает слушатель скролла (для будущих фич) - */ - setupScrollListener() { - this.scrollEl.addEventListener('scroll', () => { - // Здесь можно добавить логику, например: - // - Показать/скрыть кнопку "scroll to bottom" - // - Отметка сообщений как прочитанных - // - etc. - }, { passive: true }); - } - - /** - * Прокручивает чат в самый низ - * @param {Object} options - Опции - * @param {boolean} [options.instant=false] - Мгновенная прокрутка без анимации - * @param {boolean} [options.force=false] - Прокрутить даже если пользователь не внизу - */ - scrollToBottom(options = {}) { - const { instant = false, force = false } = options; - - // Не скроллим если загружается история (кроме force) - if (!force && this.isLoadingHistory) { - console.log('[ScrollManager] History is loading, not scrolling to bottom'); - return; - } - - // Не скроллим если пользователь читает историю (кроме force) - if (!force && !this.isNearBottom()) { - console.log('[ScrollManager] User is reading history, not scrolling'); - return; - } - - console.log('[ScrollManager] Scrolling to bottom:', { instant, force }); - - if (instant) { - // Мгновенная прокрутка - используем visibility hiding - this.scrollEl.style.visibility = 'hidden'; - - // Двойной RAF для гарантии после layout - requestAnimationFrame(() => { - requestAnimationFrame(() => { - this.scrollEl.scrollTop = this.scrollEl.scrollHeight; - this.scrollEl.style.visibility = ''; - console.log('[ScrollManager] Instant scroll complete'); - }); - }); - } else { - // Плавная прокрутка - requestAnimationFrame(() => { - this.scrollEl.scrollTop = this.scrollEl.scrollHeight; - console.log('[ScrollManager] Smooth scroll complete'); - }); - } - } - - /** - * Прокручивает к конкретному сообщению - * @param {number|string} messageId - ID сообщения - * @param {Object} options - Опции - * @param {string} [options.block='center'] - Положение ('start', 'center', 'end', 'nearest') - * @param {string} [options.behavior='smooth'] - Поведение ('auto', 'smooth') - */ - scrollToMessage(messageId, options = {}) { - const messageEl = this.scrollEl.querySelector(`[data-message-id="${messageId}"]`); - - if (!messageEl) { - console.warn('[ScrollManager] Message not found:', messageId); - return; - } - - messageEl.scrollIntoView({ - block: options.block || 'center', - behavior: options.behavior || 'smooth' - }); - - console.log('[ScrollManager] Scrolled to message:', messageId); - } - - // ==================== Утилиты ==================== - - /** - * Проверяет находится ли пользователь близко к низу - * @param {number} [threshold] - Порог в пикселях (по умолчанию this.bottomThreshold) - * @returns {boolean} - */ - isNearBottom(threshold = null) { - const th = threshold !== null ? threshold : this.bottomThreshold; - const isNear = ( - this.scrollEl.scrollTop + this.scrollEl.clientHeight >= - this.scrollEl.scrollHeight - th - ); - return isNear; - } - - /** - * Проверяет находится ли пользователь в самом верху - * @returns {boolean} - */ - isAtTop() { - return this.scrollEl.scrollTop <= 10; - } - - /** - * Получает текущую позицию скролла в процентах - * @returns {number} От 0 до 100 - */ - getScrollPercentage() { - const scrollable = this.scrollEl.scrollHeight - this.scrollEl.clientHeight; - if (scrollable <= 0) return 100; - - return (this.scrollEl.scrollTop / scrollable) * 100; - } - - /** - * Сохраняет текущую позицию скролла - * @returns {Object} Сохраненное состояние - */ - saveScrollPosition() { - return { - scrollTop: this.scrollEl.scrollTop, - scrollHeight: this.scrollEl.scrollHeight, - clientHeight: this.scrollEl.clientHeight - }; - } - - /** - * Восстанавливает позицию скролла - * @param {Object} savedPosition - Сохраненное состояние - */ - restoreScrollPosition(savedPosition) { - if (!savedPosition) return; - - const delta = this.scrollEl.scrollHeight - savedPosition.scrollHeight; - this.scrollEl.scrollTop = savedPosition.scrollTop + delta; - - console.log('[ScrollManager] Position restored:', { - delta, - newScrollTop: this.scrollEl.scrollTop - }); - } - - /** - * Получает первое видимое сообщение (элемент DOM) - * @private - * @returns {HTMLElement|null} - */ - _getFirstVisibleMessage() { - const messages = this.scrollEl.querySelectorAll('.msg[data-message-id]'); - const containerRect = this.scrollEl.getBoundingClientRect(); - - for (const msg of messages) { - const rect = msg.getBoundingClientRect(); - if (rect.top >= containerRect.top && rect.top <= containerRect.bottom) { - return msg; - } - } - - return messages[0] || null; // Если не нашли видимое, берем первое - } - - /** - * Получает ID первого видимого сообщения - * @returns {number|string|null} - */ - getFirstVisibleMessageId() { - const msg = this._getFirstVisibleMessage(); - return msg ? msg.dataset.messageId : null; - } - - /** - * Получает ID последнего видимого сообщения - * @returns {number|string|null} - */ - getLastVisibleMessageId() { - const messages = Array.from(this.scrollEl.querySelectorAll('.msg[data-message-id]')); - - for (let i = messages.length - 1; i >= 0; i--) { - const msg = messages[i]; - const rect = msg.getBoundingClientRect(); - const containerRect = this.scrollEl.getBoundingClientRect(); - - if (rect.bottom >= containerRect.top && rect.bottom <= containerRect.bottom) { - return msg.dataset.messageId; - } - } - - return null; - } - - /** - * Получает статус ScrollManager - * @returns {Object} - */ - getStatus() { - return { - isNearBottom: this.isNearBottom(), - isAtTop: this.isAtTop(), - scrollPercentage: this.getScrollPercentage(), - isLoadingHistory: this.isLoadingHistory, - firstVisibleMessage: this.getFirstVisibleMessageId(), - lastVisibleMessage: this.getLastVisibleMessageId() - }; - } -} - -export default ScrollManager; diff --git a/backend/static/js/pages/chatDetail.js b/backend/static/js/pages/chatDetail.js index 3e0be5e5..e4b956a7 100644 --- a/backend/static/js/pages/chatDetail.js +++ b/backend/static/js/pages/chatDetail.js @@ -1,265 +1,244 @@ /** - * @fileoverview Chat Detail Page - инициализация страницы детального просмотра чата + * @fileoverview Chat Detail Page - рефакторированная V2 архитектура * @module pages/chatDetail * - * НОВАЯ АРХИТЕКТУРА (v2.0): - * Использует единый ChatController для управления всеми аспектами чата: - * - MessageStore - централизованное хранилище - * - MessageLoader - загрузка и обновления - * - MessageRendererV2 - чистый рендеринг - * - ScrollManager - умная прокрутка - * - * Старые компоненты (Composer, FormManager, MarkRead) остаются для дополнительной функциональности + * V2 АРХИТЕКТУРА (чистый код): + * - Прямой импорт ChatControllerV2 без промежуточных слоев + * - MessageStoreV2 с бинарным поиском + * - MessageLoaderV2 с retry-логикой и поддержкой around/latest + * - ScrollManagerV2 с debounce/throttle + * - Нет deprecated оберток (window.messageRenderer удалены) + * - Поддержка lastReadTimestamp как fallback для скролла */ -// Асинхронная загрузка модулей (async function() { - let initChatMarkRead, initChatComposer, initChatFormManager, ChatController; + 'use strict'; - try { - console.log('[ChatDetail] Loading modules...'); - const modules = await Promise.all([ - import('../components/chatMarkRead.js'), - import('../components/chatComposer.js'), - import('../components/chatFormManager.js'), - import('../chat/index.js') // ✅ V2 автоматически (ChatController = ChatControllerV2) - ]); + // ==================== Загрузка модулей ==================== - console.log('[ChatDetail] Modules loaded:', modules.map(m => Object.keys(m))); - - initChatMarkRead = modules[0].initChatMarkRead; - initChatComposer = modules[1].initChatComposer; - initChatFormManager = modules[2].initChatFormManager; - ChatController = modules[3].ChatController; - - console.log('[ChatDetail] ChatController:', ChatController); - - if (!ChatController) { - throw new Error('ChatController is undefined - check export in chatController.js'); + let ChatControllerV2, initChatMarkRead, initChatComposer, initChatFormManager; + + try { + const [ + controllerModule, + markReadModule, + composerModule, + formManagerModule + ] = await Promise.all([ + import('../controllers/chatControllerV2.js'), + import('../components/chatMarkRead.js'), + import('../components/chatComposer.js'), + import('../components/chatFormManager.js') + ]); + + ChatControllerV2 = controllerModule.ChatControllerV2; + initChatMarkRead = markReadModule.initChatMarkRead; + initChatComposer = composerModule.initChatComposer; + initChatFormManager = formManagerModule.initChatFormManager; + + if (!ChatControllerV2) { + throw new Error('ChatControllerV2 is undefined'); + } + } catch (error) { + console.error('[ChatDetail] Failed to load modules:', error); } - } catch (error) { - console.error('[ChatDetail] Failed to load modules:', error); - alert('Ошибка загрузки модулей чата: ' + error.message); - return; // Прекращаем выполнение - } -/** - * Инициализация при готовности DOM - * Читает конфигурацию из data-атрибутов и запускает инициализацию компонентов - */ -function initWhenReady() { - // Получаем данные из data-атрибутов - const appContainer = document.getElementById('chatDetailApp'); - - if (!appContainer) { - console.warn('[ChatDetail] App container not found'); - return; - } - - // Читаем конфигурацию из data-атрибутов - const config = { - chatId: Number(appContainer.dataset.chatId), - userId: Number(appContainer.dataset.userId), - userName: appContainer.dataset.userName || 'Вы', - userAvatar: appContainer.dataset.userAvatar || '', - uploadUrl: appContainer.dataset.uploadUrl, - messagesUrl: appContainer.dataset.messagesUrl, - markReadUrl: appContainer.dataset.markReadUrl, - editUrlTemplate: appContainer.dataset.editUrlTemplate, - lastReadTimestamp: Number(appContainer.dataset.lastReadTimestamp) || null, - // Правильная обработка 0: проверяем наличие атрибута, а не значение - lastReadMessageId: appContainer.dataset.lastReadMessageId ? Number(appContainer.dataset.lastReadMessageId) : null - }; - - console.log('[ChatDetail] 🔍 lastReadMessageId from HTML:', { - rawValue: appContainer.dataset.lastReadMessageId, - parsedValue: config.lastReadMessageId, - type: typeof config.lastReadMessageId - }); - - // Читаем дополнительные URL из chatScroll (для рендеринга сообщений) - const chatScroll = document.getElementById('chatScroll'); - if (chatScroll) { - config.profileUrl = chatScroll.dataset.profileUrl; - config.detailUrlTemplate = chatScroll.dataset.detailUrlTemplate; - } - - console.log('[ChatDetail] Config:', config); - - if (!config.chatId) { - console.error('[ChatDetail] chatId is required'); - return; - } - - // Ждём инициализации WebSocket - waitForWebSocket().then((userWs) => { - console.log('[ChatDetail] WebSocket ready, initializing components'); - initializeComponents(config, userWs); - }).catch((error) => { - console.error('[ChatDetail] WebSocket initialization failed:', error); - }); -} + // ==================== Конфигурация ==================== -// Запускаем инициализацию -if (document.readyState === 'loading') { - document.addEventListener('DOMContentLoaded', initWhenReady); -} else { - // DOM уже готов - initWhenReady(); -} + /** + * Читает конфигурацию из DOM + * @returns {Object|null} + */ + function readConfig() { + const appContainer = document.getElementById('chatDetailApp'); + if (!appContainer) { + console.warn('[ChatDetailV2] App container not found'); + return null; + } -/** - * Ожидание готовности WebSocket соединения - * @returns {Promise} Промис с API WebSocket - * @throws {Error} Если WebSocket не инициализирован за 10 секунд - */ -function waitForWebSocket() { - return new Promise((resolve, reject) => { - // Если уже готов - используем сразу - if (window.userWebSocket && window.userWebSocket.ws) { - console.log('[ChatDetail] WebSocket already available'); - resolve(window.userWebSocket); - return; + const scrollContainer = document.getElementById('chatScroll'); + + const config = { + chatId: Number(appContainer.dataset.chatId), + userId: Number(appContainer.dataset.userId), + userName: appContainer.dataset.userName || 'Вы', + userAvatar: appContainer.dataset.userAvatar || '', + uploadUrl: appContainer.dataset.uploadUrl, + messagesUrl: appContainer.dataset.messagesUrl, + markReadUrl: appContainer.dataset.markReadUrl, + editUrlTemplate: appContainer.dataset.editUrlTemplate, + lastReadTimestamp: Number(appContainer.dataset.lastReadTimestamp) || null, + lastReadMessageId: appContainer.dataset.lastReadMessageId + ? Number(appContainer.dataset.lastReadMessageId) + : null, + profileUrl: scrollContainer?.dataset.profileUrl || '/employees/profile/', + detailUrlTemplate: scrollContainer?.dataset.detailUrlTemplate || '/employees/detail/0/' + }; + + console.log('[ChatDetailV2] 🔍 Config:', { + lastReadMessageId: config.lastReadMessageId, + lastReadTimestamp: config.lastReadTimestamp, + rawMessageId: appContainer.dataset.lastReadMessageId, + rawTimestamp: appContainer.dataset.lastReadTimestamp + }); + + return config; } - - // Ждём события с таймаутом - console.log('[ChatDetail] Waiting for user:ws-ready event...'); - let timeoutId; - - const handler = (e) => { - console.log('[ChatDetail] user:ws-ready event received'); - clearTimeout(timeoutId); - resolve(e.detail.api); - }; - - window.addEventListener('user:ws-ready', handler, { once: true }); - - timeoutId = setTimeout(() => { - window.removeEventListener('user:ws-ready', handler); - reject(new Error('WebSocket initialization timeout')); - }, 10000); // 10 секунд - }); -} -/** - * Инициализация всех компонентов страницы чата - * @param {Object} config - Конфигурация из data-атрибутов - * @param {number} config.chatId - ID чата - * @param {number} config.userId - ID текущего пользователя - * @param {string} config.userName - Имя текущего пользователя - * @param {string} config.userAvatar - URL аватара пользователя - * @param {string} config.uploadUrl - URL для загрузки файлов - * @param {string} config.messagesUrl - URL для загрузки истории - * @param {string} config.markReadUrl - URL для отметки прочтения - * @param {Object} userWs - API WebSocket соединения - */ -async function initializeComponents(config, userWs) { - console.log('[ChatDetail] 🚀 Initializing with NEW ChatController architecture...'); - - const scrollElement = document.getElementById('chatScroll'); - if (!scrollElement) { - console.error('[ChatDetail] Scroll container not found'); - return; - } + /** + * Ждет готовности WebSocket + * @param {number} timeout - Таймаут в мс + * @returns {Promise} + */ + function waitForWebSocket(timeout = 10000) { + return new Promise((resolve, reject) => { + // Уже готов + if (window.userWebSocket?.ws) { + resolve(window.userWebSocket); + return; + } - // ========== 0. ОТКРЫВАЕМ ЧАТ В WEBSOCKET (ДО ChatController) ========== - // ВАЖНО: Открываем ДО инициализации чтобы избежать late initial_messages - // loadHistory=false - не загружаем через WS, ChatController загрузит через HTTP - if (userWs && userWs.openChat) { - userWs.openChat(config.chatId, false); - console.log('[ChatDetail] ✅ Chat opened in WebSocket (before init)'); - } - - // ========== 1. CHAT CONTROLLER (НОВАЯ АРХИТЕКТУРА) ========== - // Один контроллер заменяет: MessageRenderer, MessageLoader, ScrollManager, HistoryLoader - console.log('[ChatDetail] Creating ChatController...'); - - const chatController = new ChatController({ - chatId: config.chatId, - currentUserId: config.userId, - scrollElement: scrollElement, - containerId: 'chatScroll', - wsConnection: userWs, - profileUrl: config.profileUrl || '/employees/profile/', - detailUrlTemplate: config.detailUrlTemplate || '/employees/detail/0/', - messagesUrl: config.messagesUrl, - lastReadMessageId: config.lastReadMessageId, // ✅ Передаем message_id - lastReadTimestamp: config.lastReadTimestamp // ✅ Fallback на timestamp - }); - - console.log('[ChatDetail] Initializing ChatController...'); - await chatController.init(); - console.log('[ChatDetail] ✅ ChatController initialized'); - - // ========== 2. FORM MANAGER ========== - // Управление формой редактирования сообщений - const formManager = initChatFormManager({ - formId: 'chatForm', - textareaId: 'id_content', - chatId: config.chatId, - uploadUrl: config.uploadUrl, - editUrlTemplate: config.editUrlTemplate - }); - - if (!formManager) { - console.error('[ChatDetail] FormManager initialization failed'); - return; - } - console.log('[ChatDetail] ✅ FormManager initialized'); - - // ========== 3. MARK READ API ========== - // Отслеживание прочитанных сообщений - const markReadApi = initChatMarkRead({ - chatId: config.chatId, - meId: config.userId, - scrollContainerId: 'chatScroll', - textareaId: 'id_content', - formId: 'chatForm', - scrollBtnId: 'scrollBtn', - markReadUrl: config.markReadUrl, - initialLastReadTs: config.lastReadTimestamp - }); - console.log('[ChatDetail] ✅ MarkRead initialized'); - - // ========== 4. COMPOSER ========== - // Отправка новых сообщений (использует ChatController для добавления) - const composer = initChatComposer({ - chatId: config.chatId, - formId: 'chatForm', - textareaId: 'id_content', - uploadUrl: config.uploadUrl, - meId: config.userId, - meName: config.userName, - meAvatar: config.userAvatar, - chatController: chatController // НОВОЕ: передаем контроллер вместо renderer - }); - - if (!composer) { - console.error('[ChatDetail] Composer initialization failed'); - return; - } - console.log('[ChatDetail] ✅ Composer initialized'); - - // ========== 5. EXPORT TO WINDOW ========== - // Для доступа из других скриптов и отладки - window.chatController = chatController; - window.chatComposer = composer; - window.markReadApi = markReadApi; - window.chatFormManager = formManager; - - // DEPRECATED: Старые API для backward compatibility - window.messageRenderer = { - renderMessage: (msg) => console.warn('[DEPRECATED] Use chatController instead'), - renderMessages: (msgs) => console.warn('[DEPRECATED] Use chatController instead') - }; - window.chatHistoryLoader = { - loadMore: () => chatController.loadMoreHistory() - }; - - console.log('[ChatDetail] ✅ All components initialized successfully'); - console.log('[ChatDetail] 📊 Available APIs: window.chatController, window.chatComposer, window.markReadApi'); - - // Контекстное меню и выделение сообщений инициализируются в chat-detail-enhanced.js -} + let timeoutId; + + const handler = (e) => { + clearTimeout(timeoutId); + resolve(e.detail.api); + }; -})(); // Закрываем async IIFE \ No newline at end of file + window.addEventListener('user:ws-ready', handler, { once: true }); + + timeoutId = setTimeout(() => { + window.removeEventListener('user:ws-ready', handler); + reject(new Error('WebSocket connection timeout')); + }, timeout); + }); + } + + // ==================== Инициализация компонентов ==================== + + /** + * Инициализирует все компоненты чата + * @param {Object} config - Конфигурация + * @param {Object} wsApi - WebSocket API + */ + async function initializeComponents(config, wsApi) { + const scrollElement = document.getElementById('chatScroll'); + if (!scrollElement) { + console.error('[ChatDetailV2] Scroll container not found'); + return; + } + + // 0. Открываем чат в WebSocket ДО инициализации + if (wsApi?.openChat) { + wsApi.openChat(config.chatId, false); + } + + // 1. Chat Controller (главный координатор) + const chatController = new ChatControllerV2({ + chatId: config.chatId, + currentUserId: config.userId, + scrollElement, + containerId: 'chatScroll', + wsConnection: wsApi, + profileUrl: config.profileUrl, + detailUrlTemplate: config.detailUrlTemplate, + lastReadMessageId: config.lastReadMessageId, + lastReadTimestamp: config.lastReadTimestamp // ✅ Fallback для скролла + }); + + await chatController.init(); + console.log('[ChatDetailV2] ChatController initialized'); + + // 2. Form Manager (редактирование сообщений) + const formManager = initChatFormManager({ + formId: 'chatForm', + textareaId: 'id_content', + chatId: config.chatId, + uploadUrl: config.uploadUrl, + editUrlTemplate: config.editUrlTemplate + }); + + // 3. Mark Read (отслеживание прочитанных) + const markReadApi = initChatMarkRead({ + chatId: config.chatId, + meId: config.userId, + scrollContainerId: 'chatScroll', + textareaId: 'id_content', + formId: 'chatForm', + scrollBtnId: 'scrollBtn', + markReadUrl: config.markReadUrl, + initialLastReadTs: config.lastReadMessageId + }); + + // 4. Composer (отправка сообщений) + const composer = initChatComposer({ + chatId: config.chatId, + formId: 'chatForm', + textareaId: 'id_content', + uploadUrl: config.uploadUrl, + meId: config.userId, + meName: config.userName, + meAvatar: config.userAvatar, + chatController + }); + + // 5. Экспорт в window + window.chatController = chatController; + window.chatComposer = composer; + window.markReadApi = markReadApi; + window.chatFormManager = formManager; + + // Backward compatibility + window.messageRenderer = { + renderMessage: () => console.warn('[DEPRECATED] Use chatController'), + renderMessages: () => console.warn('[DEPRECATED] Use chatController') + }; + window.chatHistoryLoader = { + loadMore: () => chatController.loadMoreHistory() + }; + + console.log('[ChatDetailV2] All components initialized'); + } + + // ==================== Entry Point ==================== + + /** + * Главная функция инициализации + */ + async function init() { + console.log('[ChatDetailV2] 🚀🚀🚀 INIT STARTED - V2 FILE LOADED 🚀🚀🚀'); + const config = readConfig(); + console.log('[ChatDetailV2] 🔍 Config read result:', config); + if (!config || !config.chatId) { + console.error('[ChatDetailV2] Invalid configuration'); + return; + } + + try { + const wsApi = await waitForWebSocket(); + await initializeComponents(config, wsApi); + } catch (error) { + console.error('[ChatDetailV2] Initialization failed:', error); + + // Показываем ошибку пользователю + const scrollEl = document.getElementById('chatScroll'); + if (scrollEl) { + scrollEl.innerHTML = ` +
+ + Ошибка загрузки чата. + Обновить страницу +
+ `; + } + } + } + + // Запуск + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', init); + } else { + init(); + } + +})(); diff --git a/backend/static/js/pages/chatDetailTest.js b/backend/static/js/pages/chatDetailTest.js deleted file mode 100644 index f74c1d27..00000000 --- a/backend/static/js/pages/chatDetailTest.js +++ /dev/null @@ -1,29 +0,0 @@ -/** - * Chat Detail Page - TEST VERSION - * Минимальная версия для проверки загрузки - */ - -console.log('[ChatDetailTest] ========== MODULE LOADED =========='); - -document.addEventListener('DOMContentLoaded', () => { - console.log('[ChatDetailTest] ========== DOM READY =========='); - - const appContainer = document.getElementById('chatDetailApp'); - console.log('[ChatDetailTest] App container:', appContainer); - - if (appContainer) { - console.log('[ChatDetailTest] Data attributes:', appContainer.dataset); - } - - // Простая привязка к кнопке - const submitBtn = document.querySelector('.btn-send'); - console.log('[ChatDetailTest] Submit button:', submitBtn); - - if (submitBtn) { - submitBtn.addEventListener('click', (e) => { - console.log('[ChatDetailTest] BUTTON CLICKED!'); - e.preventDefault(); - alert('Button clicked! This works!'); - }); - } -}); diff --git a/backend/static/js/pages/chatDetailV2.js b/backend/static/js/pages/chatDetailV2.js deleted file mode 100644 index c2a4d925..00000000 --- a/backend/static/js/pages/chatDetailV2.js +++ /dev/null @@ -1,244 +0,0 @@ -/** - * @fileoverview Chat Detail Page V2 - рефакторированная инициализация чата - * @module pages/chatDetailV2 - * - * НОВАЯ АРХИТЕКТУРА V2: - * - Использует ChatControllerV2 с чистой архитектурой - * - MessageStoreV2 с бинарным поиском - * - MessageLoaderV2 с retry-логикой - * - ScrollManagerV2 с debounce/throttle - * - Минимум логирования - */ - -(async function() { - 'use strict'; - - // ==================== Загрузка модулей ==================== - - let ChatControllerV2, initChatMarkRead, initChatComposer, initChatFormManager; - - try { - const [ - controllerModule, - markReadModule, - composerModule, - formManagerModule - ] = await Promise.all([ - import('../controllers/chatControllerV2.js'), - import('../components/chatMarkRead.js'), - import('../components/chatComposer.js'), - import('../components/chatFormManager.js') - ]); - - ChatControllerV2 = controllerModule.ChatControllerV2; - initChatMarkRead = markReadModule.initChatMarkRead; - initChatComposer = composerModule.initChatComposer; - initChatFormManager = formManagerModule.initChatFormManager; - - if (!ChatControllerV2) { - throw new Error('ChatControllerV2 is undefined'); - } - } catch (error) { - console.error('[ChatDetailV2] Failed to load modules:', error); - return; - } - - // ==================== Конфигурация ==================== - - /** - * Читает конфигурацию из DOM - * @returns {Object|null} - */ - function readConfig() { - const appContainer = document.getElementById('chatDetailApp'); - if (!appContainer) { - console.warn('[ChatDetailV2] App container not found'); - return null; - } - - const scrollContainer = document.getElementById('chatScroll'); - - const config = { - chatId: Number(appContainer.dataset.chatId), - userId: Number(appContainer.dataset.userId), - userName: appContainer.dataset.userName || 'Вы', - userAvatar: appContainer.dataset.userAvatar || '', - uploadUrl: appContainer.dataset.uploadUrl, - messagesUrl: appContainer.dataset.messagesUrl, - markReadUrl: appContainer.dataset.markReadUrl, - editUrlTemplate: appContainer.dataset.editUrlTemplate, - lastReadTimestamp: Number(appContainer.dataset.lastReadTimestamp) || null, - lastReadMessageId: appContainer.dataset.lastReadMessageId - ? Number(appContainer.dataset.lastReadMessageId) - : null, - profileUrl: scrollContainer?.dataset.profileUrl || '/employees/profile/', - detailUrlTemplate: scrollContainer?.dataset.detailUrlTemplate || '/employees/detail/0/' - }; - - console.log('[ChatDetailV2] 🔍 Config:', { - lastReadMessageId: config.lastReadMessageId, - lastReadTimestamp: config.lastReadTimestamp, - rawMessageId: appContainer.dataset.lastReadMessageId, - rawTimestamp: appContainer.dataset.lastReadTimestamp - }); - - return config; - } - - /** - * Ждет готовности WebSocket - * @param {number} timeout - Таймаут в мс - * @returns {Promise} - */ - function waitForWebSocket(timeout = 10000) { - return new Promise((resolve, reject) => { - // Уже готов - if (window.userWebSocket?.ws) { - resolve(window.userWebSocket); - return; - } - - let timeoutId; - - const handler = (e) => { - clearTimeout(timeoutId); - resolve(e.detail.api); - }; - - window.addEventListener('user:ws-ready', handler, { once: true }); - - timeoutId = setTimeout(() => { - window.removeEventListener('user:ws-ready', handler); - reject(new Error('WebSocket connection timeout')); - }, timeout); - }); - } - - // ==================== Инициализация компонентов ==================== - - /** - * Инициализирует все компоненты чата - * @param {Object} config - Конфигурация - * @param {Object} wsApi - WebSocket API - */ - async function initializeComponents(config, wsApi) { - const scrollElement = document.getElementById('chatScroll'); - if (!scrollElement) { - console.error('[ChatDetailV2] Scroll container not found'); - return; - } - - // 0. Открываем чат в WebSocket ДО инициализации - if (wsApi?.openChat) { - wsApi.openChat(config.chatId, false); - } - - // 1. Chat Controller (главный координатор) - const chatController = new ChatControllerV2({ - chatId: config.chatId, - currentUserId: config.userId, - scrollElement, - containerId: 'chatScroll', - wsConnection: wsApi, - profileUrl: config.profileUrl, - detailUrlTemplate: config.detailUrlTemplate, - lastReadMessageId: config.lastReadMessageId, - lastReadTimestamp: config.lastReadTimestamp // ✅ Fallback для скролла - }); - - await chatController.init(); - console.log('[ChatDetailV2] ChatController initialized'); - - // 2. Form Manager (редактирование сообщений) - const formManager = initChatFormManager({ - formId: 'chatForm', - textareaId: 'id_content', - chatId: config.chatId, - uploadUrl: config.uploadUrl, - editUrlTemplate: config.editUrlTemplate - }); - - // 3. Mark Read (отслеживание прочитанных) - const markReadApi = initChatMarkRead({ - chatId: config.chatId, - meId: config.userId, - scrollContainerId: 'chatScroll', - textareaId: 'id_content', - formId: 'chatForm', - scrollBtnId: 'scrollBtn', - markReadUrl: config.markReadUrl, - initialLastReadTs: config.lastReadMessageId - }); - - // 4. Composer (отправка сообщений) - const composer = initChatComposer({ - chatId: config.chatId, - formId: 'chatForm', - textareaId: 'id_content', - uploadUrl: config.uploadUrl, - meId: config.userId, - meName: config.userName, - meAvatar: config.userAvatar, - chatController - }); - - // 5. Экспорт в window - window.chatController = chatController; - window.chatComposer = composer; - window.markReadApi = markReadApi; - window.chatFormManager = formManager; - - // Backward compatibility - window.messageRenderer = { - renderMessage: () => console.warn('[DEPRECATED] Use chatController'), - renderMessages: () => console.warn('[DEPRECATED] Use chatController') - }; - window.chatHistoryLoader = { - loadMore: () => chatController.loadMoreHistory() - }; - - console.log('[ChatDetailV2] All components initialized'); - } - - // ==================== Entry Point ==================== - - /** - * Главная функция инициализации - */ - async function init() { - console.log('[ChatDetailV2] 🚀🚀🚀 INIT STARTED - V2 FILE LOADED 🚀🚀🚀'); - const config = readConfig(); - console.log('[ChatDetailV2] 🔍 Config read result:', config); - if (!config || !config.chatId) { - console.error('[ChatDetailV2] Invalid configuration'); - return; - } - - try { - const wsApi = await waitForWebSocket(); - await initializeComponents(config, wsApi); - } catch (error) { - console.error('[ChatDetailV2] Initialization failed:', error); - - // Показываем ошибку пользователю - const scrollEl = document.getElementById('chatScroll'); - if (scrollEl) { - scrollEl.innerHTML = ` -
- - Ошибка загрузки чата. - Обновить страницу -
- `; - } - } - } - - // Запуск - if (document.readyState === 'loading') { - document.addEventListener('DOMContentLoaded', init); - } else { - init(); - } - -})(); diff --git a/backend/templates/communications/chat_detail.html b/backend/templates/communications/chat_detail.html index 6f0b6897..63705b81 100644 --- a/backend/templates/communications/chat_detail.html +++ b/backend/templates/communications/chat_detail.html @@ -311,8 +311,8 @@ {# ========== CHAT DETAIL PAGE - CORE FUNCTIONALITY ========== #} - {# chatDetailV2.js - чистая архитектура с прямым импортом V2 #} - + {# chatDetail.js - V2 архитектура с прямым импортом ChatControllerV2 #} + {# ========== CHAT DETAIL - UI ENHANCEMENTS ========== #} {# Реакции, контекстное меню, выделение, голосования #}