refactor: удален legacy код, chatDetailV2 переименован в chatDetail

УДАЛЕНО (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 мертвого кода
This commit is contained in:
Игорь Надеин 2026-01-13 10:14:11 +03:00
parent 7ad2cef7cc
commit c8a64aa664
7 changed files with 230 additions and 1830 deletions

View File

@ -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 = `<span class="px-3 py-1 rounded-pill bg-light">${msgDay}</span>`;
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 = `
<div class="day-divider text-center small text-muted my-3">
<span class="px-3 py-1 rounded-pill bg-light">${dayText}</span>
</div>
`;
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 = `<span class="px-3 py-1 rounded-pill bg-light">${msgDay}</span>`;
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 = '<span class="spinner-border spinner-border-sm me-2" role="status" aria-hidden="true"></span>Отправляем…';
bubble.appendChild(pendingStatus);
}
wrap.innerHTML = tempDiv.innerHTML;
} else {
wrap.innerHTML = htmlContent;
}
return wrap;
}
/**
* Строит HTML одного сообщения
*/
buildMessageHtml(msg, isOwn) {
return `
<div class="d-flex mb-3 msg ${isOwn ? 'justify-content-end' : 'justify-content-start'}"
data-id="${msg.id}"
data-message-id="${msg.id}"
data-ts="${msg.created_ts}"
data-author-id="${msg.author_id}"
data-is-edited="${msg.is_edited || false}"
data-edited-at="${msg.edited_at || ''}"
data-reactions='${JSON.stringify(msg.reactions_summary || {})}'>
${this.buildMessageInnerHtml(msg, isOwn)}
</div>`;
}
/**
* Строит внутренний 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 ? '' : `<a class="me-2 text-decoration-none" href="${authorUrl}"><span class="mini-ava border">${msg.avatar ? `<img src="${msg.avatar}" alt="" loading="lazy">` : '<i class="bi-person"></i>'}</span></a>`;
const forwardedHtml = msg.is_forwarded && msg.forwarded_from ? `<div class="forwarded-indicator small mb-2 d-flex align-items-center"><i class="bi-arrow-90deg-right me-2"></i><div><div>Переслано от <strong>${msg.forwarded_from.author_name || 'Пользователя'}</strong>${msg.forwarded_from.chat_name ? ` из «${msg.forwarded_from.chat_name}»` : ''}</div>${msg.forwarded_from.created_at ? `<div class="small opacity-75">${msg.forwarded_from.created_at}</div>` : ''}</div></div>` : '';
const replyHtml = msg.reply_to ? `<div class="reply-reference small mb-2" style="border-left:3px solid var(--bs-primary);padding-left:8px;opacity:0.8;cursor:pointer;" onclick="document.querySelector('[data-message-id=\\\"${msg.reply_to.id}\\\"]')?.scrollIntoView({behavior:'smooth',block:'center'})"><div class="fw-semibold">${msg.reply_to.author_name || 'Пользователь'}</div><div class="text-truncate">${this.escapeHtml(msg.reply_to.content || '[файл]')}</div></div>` : '';
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 ?
`<span class="message-edited-indicator text-muted small ms-2" style="font-size:0.75rem;font-style:italic;">(изменено)</span>` : '';
// Реакции теперь рендерятся через message-reactions-wrapper ВНЕ bubble
// Старая система buildReactionsHtml() больше не используется
const timeHtml = this.formatTime(msg.created_ts);
// Аватар справа для своих сообщений
const rightAvatarHtml = isOwn ? `<a class="ms-2 text-decoration-none" href="${this.profileUrl}"><span class="mini-ava border">${this.currentUserAvatar ? `<img src="${this.currentUserAvatar}" alt="" loading="lazy">` : '<i class="bi-person"></i>'}</span></a>` : '';
return `${avatarHtml}<div class="d-flex flex-column" style="max-width:80%;"><div class="small text-secondary ${isOwn ? 'text-end' : ''}"><a href="${authorUrl}" class="text-decoration-none ${isOwn ? '' : 'fw-semibold'}">${authorName}</a> · <time datetime="${new Date(msg.created_ts).toISOString()}">${timeHtml}</time>${editedIndicator}</div><div class="mt-1 bubble ${isOwn ? 'bubble-me' : 'bubble-other'}">${forwardedHtml}${replyHtml}${msg.content ? this.escapeHtml(msg.content) : ''}${attachmentsHtml}${pollHtml}</div><div class="message-reactions-wrapper mt-1"></div></div>${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 `<div class="attachment-item mb-2"><a href="${attachment.file_url}" target="_blank"><img src="${attachment.file_url}" alt="${this.escapeHtml(fileName)}" class="img-fluid rounded" style="max-width:100%;max-height:300px;" loading="lazy"></a></div>`;
}
return `<div class="attachment-item mb-2"><a href="${attachment.file_url}" target="_blank" class="d-flex align-items-center gap-2 text-decoration-none rounded-3 px-3 py-2" title="${this.escapeHtml(fileName)}"><i class="bi-file-earmark"></i><span class="text-truncate fw-semibold" style="max-width:200px;">${this.escapeHtml(fileName)}</span></a></div>`;
}
/**
* Строит 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 `<div class="poll-widget" data-poll-id="${poll.id}"><div class="poll-question mb-2"><strong>${this.escapeHtml(poll.question)}</strong></div><div class="poll-options">${poll.options?.map(option => `<div class="poll-option mb-2" data-option-id="${option.id}"><button type="button" class="btn btn-outline-secondary btn-poll-option w-100 text-start" data-option-id="${option.id}">${this.escapeHtml(option.text)}</button></div>`).join('') || ''}</div><div class="poll-footer mt-2 d-flex justify-content-between align-items-center"><div class="small text-muted">${totalVotes} проголосовало${poll.is_anonymous ? ' • Анонимное' : ''}${poll.is_multiple_choice ? ' • Множественный выбор' : ''}${isClosed ? ' • Закрыто' : ''}</div></div></div>`;
}
/**
* Форматирует дату/время
*/
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);
}

View File

@ -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<number, {initial: boolean, history: boolean, status: string}>} */
this.loadingState = new Map();
/** @type {Map<number, {hasMore: boolean, oldestId: number|null}>} */
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<Object>} Результат загрузки
*/
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<Array>} Массив сообщений
*/
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;

View File

@ -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;

View File

@ -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<Object>} Промис с 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<Object>}
*/
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
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 = `
<div class="alert alert-danger m-3">
<i class="bi bi-exclamation-triangle me-2"></i>
Ошибка загрузки чата.
<a href="#" onclick="location.reload()">Обновить страницу</a>
</div>
`;
}
}
}
// Запуск
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();

View File

@ -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!');
});
}
});

View File

@ -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<Object>}
*/
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 = `
<div class="alert alert-danger m-3">
<i class="bi bi-exclamation-triangle me-2"></i>
Ошибка загрузки чата.
<a href="#" onclick="location.reload()">Обновить страницу</a>
</div>
`;
}
}
}
// Запуск
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();

View File

@ -311,8 +311,8 @@
<script type="module" src="{% static 'vendor/emoji-picker/index.js' %}"></script>
{# ========== CHAT DETAIL PAGE - CORE FUNCTIONALITY ========== #}
{# chatDetailV2.js - чистая архитектура с прямым импортом V2 #}
<script type="module" src="{% static 'js/pages/chatDetailV2.js' %}"></script>
{# chatDetail.js - V2 архитектура с прямым импортом ChatControllerV2 #}
<script type="module" src="{% static 'js/pages/chatDetail.js' %}"></script>
{# ========== CHAT DETAIL - UI ENHANCEMENTS ========== #}
{# Реакции, контекстное меню, выделение, голосования #}