refactor(messenger): Phase 2 - унификация рендеринга сообщений

- Добавлен метод MessageRenderer.createMessageElement() для создания DOM элементов
- Обновлен chatComposer для использования MessageRenderer
- Обновлен chatHistoryLoader для использования MessageRenderer
- Обновлен chatDetail.js для передачи messageRenderer в модули
- Все модули теперь используют единый источник рендеринга
- Сохранена обратная совместимость через fallback на createMessageElement()

Phase 2 of 4: Rendering unification completed
This commit is contained in:
Игорь Надеин 2026-01-12 10:42:11 +03:00
parent 3433886be3
commit 86791f7bf3
4 changed files with 110 additions and 26 deletions

View File

@ -3,6 +3,8 @@ import { createMessageElement } from './chatMessageTemplates.js';
/**
* Chat Composer module
* Отвечает за ввод текста, прикрепление файлов и отправку сообщений через REST API.
*
* РЕФАКТОРИНГ: Теперь использует MessageRenderer для создания pending сообщений
*/
const DEFAULT_CONFIG = {
@ -49,6 +51,7 @@ class ChatComposer {
this.meAvatar = options.meAvatar || form.dataset.meAvatar || this.scrollEl?.dataset.meAvatar || '';
this.profileUrl = options.profileUrl || form.dataset.profileUrl || this.options.profileUrl;
this.detailUrlTemplate = options.detailUrlTemplate || form.dataset.detailUrlTemplate || this.options.detailUrlTemplate;
this.messageRenderer = options.messageRenderer || null; // РЕФАКТОРИНГ: Принимаем MessageRenderer
this.selectedFiles = [];
this.typingThrottle = 1200;
this.lastTypingSent = 0;
@ -544,11 +547,19 @@ class ChatComposer {
is_pending: true
};
const element = createMessageElement(msg, {
meId: this.meId,
profileUrl: this.profileUrl,
detailUrlTemplate: this.detailUrlTemplate
});
// РЕФАКТОРИНГ: Используем MessageRenderer если доступен, иначе fallback на createMessageElement
let element;
if (this.messageRenderer) {
element = this.messageRenderer.createMessageElement(msg, true); // true = isPending
} else {
// Fallback для обратной совместимости
element = createMessageElement(msg, {
meId: this.meId,
profileUrl: this.profileUrl,
detailUrlTemplate: this.detailUrlTemplate
});
}
element.dataset.pendingId = pendingId;
this.scrollEl.appendChild(element);
this.scrollEl.scrollTop = this.scrollEl.scrollHeight;

View File

@ -5,6 +5,12 @@ import {
toTimestamp
} from './chatMessageTemplates.js';
/**
* Chat History Loader - загрузка ранних сообщений при прокрутке вверх
*
* РЕФАКТОРИНГ: Теперь использует MessageRenderer для создания элементов сообщений
*/
const DEFAULT_CONFIG = {
scrollSelector: '#chatScroll',
loaderSelector: '[data-history-loader]',
@ -67,6 +73,7 @@ export function initChatHistoryLoader(options = {}) {
const profileUrl = scrollEl.dataset.profileUrl || '/employees/profile/';
const detailUrlTemplate = scrollEl.dataset.detailUrlTemplate || '/employees/0/';
const avatarMap = getAvatarMapSnapshot();
const messageRenderer = options.messageRenderer || null; // РЕФАКТОРИНГ: Принимаем MessageRenderer
const state = {
loading: false,
@ -113,6 +120,7 @@ export function initChatHistoryLoader(options = {}) {
profileUrl,
detailUrlTemplate,
avatarMap,
messageRenderer,
state
} = ctx;
@ -129,12 +137,20 @@ export function initChatHistoryLoader(options = {}) {
}
prevDay = day;
const element = createMessageElement(msg, {
meId,
profileUrl,
detailUrlTemplate,
avatarMap
});
// РЕФАКТОРИНГ: Используем MessageRenderer если доступен
let element;
if (messageRenderer) {
element = messageRenderer.createMessageElement(msg, false);
} else {
// Fallback для обратной совместимости
element = createMessageElement(msg, {
meId,
profileUrl,
detailUrlTemplate,
avatarMap
});
}
element.classList.remove('message-pending', 'message-pending--resolved');
fragment.appendChild(element);
});
@ -186,6 +202,7 @@ export function initChatHistoryLoader(options = {}) {
profileUrl,
detailUrlTemplate,
avatarMap,
messageRenderer,
state
});
state.oldestId = String(payload.messages[0].id);

View File

@ -120,6 +120,60 @@ export class MessageRenderer {
}
}
/**
* Создаёт 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 одного сообщения
*/

View File

@ -146,6 +146,19 @@ function initializeComponents(config, userWs) {
console.log('[ChatDetail] MarkRead initialized');
// ========== MESSAGE RENDERER ==========
console.log('[ChatDetail] Initializing MessageRenderer...');
const messageRenderer = new MessageRenderer({
containerId: 'chatScroll',
currentUserId: config.userId,
currentUserAvatar: config.userAvatar || '',
profileUrl: config.profileUrl || '/employees/profile/',
detailUrlTemplate: config.detailUrlTemplate || '/employees/detail/0/'
});
console.log('[ChatDetail] MessageRenderer initialized');
// ========== COMPOSER ==========
console.log('[ChatDetail] Initializing Composer...');
const composer = initChatComposer({
@ -155,7 +168,8 @@ function initializeComponents(config, userWs) {
uploadUrl: config.uploadUrl,
meId: config.userId,
meName: config.userName,
meAvatar: config.userAvatar
meAvatar: config.userAvatar,
messageRenderer: messageRenderer // Передаём MessageRenderer
});
if (!composer) {
@ -176,24 +190,12 @@ function initializeComponents(config, userWs) {
scrollContainerId: 'chatScroll',
fetchUrl: config.messagesUrl,
avatarMap: avatarMap,
meId: config.userId
meId: config.userId,
messageRenderer: messageRenderer // Передаём MessageRenderer
});
console.log('[ChatDetail] HistoryLoader initialized');
// ========== MESSAGE RENDERER ==========
console.log('[ChatDetail] Initializing MessageRenderer...');
const messageRenderer = new MessageRenderer({
containerId: 'chatScroll',
currentUserId: config.userId,
currentUserAvatar: config.userAvatar || '',
profileUrl: config.profileUrl || '/employees/profile/',
detailUrlTemplate: config.detailUrlTemplate || '/employees/detail/0/'
});
console.log('[ChatDetail] MessageRenderer initialized');
// ========== CONTEXT MENU ==========
// Инициализация контекстного меню перенесена в chat-detail-enhanced.js
// для избежания конфликтов и дублирования