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:
parent
7ad2cef7cc
commit
c8a64aa664
@ -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);
|
||||
}
|
||||
@ -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;
|
||||
@ -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;
|
||||
@ -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();
|
||||
}
|
||||
|
||||
})();
|
||||
|
||||
@ -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!');
|
||||
});
|
||||
}
|
||||
});
|
||||
@ -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();
|
||||
}
|
||||
|
||||
})();
|
||||
@ -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 ========== #}
|
||||
{# Реакции, контекстное меню, выделение, голосования #}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user