Corp/backend/static/js/components/messageContextMenu.js
Игорь Надеин d5be62b34c refactor: восстановлены старые стили пузырей сообщений, аватаров и разделителей (pre-redesign)
- Восстановлен мягкий цвет пузырей: color-mix(92%) вместо жесткого var(--bs-primary)
- Аватары: border-radius 9999px + grid layout для идеальных кругов
- Разделители: правильный spacing, font-weight 700, pointer-events, color-mix прозрачность
- Добавлены оптимизации скролла: rootMargin 300px, limit 50 сообщений (Telegram-стиль)
- Добавлены новые модули: messageLoader, scrollManager, chatUtils
- Рефакторинг логики загрузки сообщений для предотвращения дубликатов
- Создана документация по архитектуре и тестированию
2026-01-12 17:21:41 +03:00

687 lines
26 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// backend/static/js/components/messageContextMenu.js
/**
* Контекстное меню для сообщений (как в Telegram/WhatsApp)
* - Мобилка: долгое нажатие
* - Десктоп: правый клик
*/
export class MessageContextMenu {
constructor(config = {}) {
console.log('[MessageContextMenu] Constructor called', config);
this.onReactionSelect = config.onReactionSelect || (() => {});
this.emojis = config.emojis || ['👍', '❤️', '😂', '😮', '😢', '🙏', '👏', '🔥'];
this.currentUserId = config.currentUserId;
this.containerId = config.containerId || 'chatScroll';
this.activeMenu = null;
this.highlightedMessage = null; // Храним ссылку на подсвеченное сообщение
this.longPressTimer = null;
this.longPressDuration = 500; // 500ms для долгого нажатия
this.init();
this.attachToExistingMessages();
this.observeNewMessages();
}
init() {
// Закрытие меню при клике вне его
document.addEventListener('click', (e) => {
if (this.activeMenu && !e.target.closest('.message-context-menu')) {
this.removeHighlight(); // Убираем подсветку
this.closeMenu();
}
});
// Закрытие при Escape
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && this.activeMenu) {
this.removeHighlight(); // Убираем подсветку
this.closeMenu();
}
});
// Закрытие при скролле
document.addEventListener('scroll', () => {
if (this.activeMenu) {
this.removeHighlight(); // Убираем подсветку
this.closeMenu();
}
}, true);
}
/**
* Подключить контекстное меню ко всем существующим сообщениям
*/
attachToExistingMessages() {
console.log('[MessageContextMenu] Attaching to existing messages in:', this.containerId);
const container = document.getElementById(this.containerId);
if (!container) {
console.warn('[MessageContextMenu] Container not found:', this.containerId);
return;
}
const messages = container.querySelectorAll('.msg[data-message-id]');
console.log('[MessageContextMenu] Found', messages.length, 'messages');
messages.forEach((msg) => {
if (!msg.dataset.contextMenuAttached) {
this.attachToMessage(msg);
msg.dataset.contextMenuAttached = 'true';
}
});
}
/**
* Наблюдать за новыми сообщениями через MutationObserver
*/
observeNewMessages() {
const container = document.getElementById(this.containerId);
if (!container) {
console.warn('[MessageContextMenu] Container not found for observer:', this.containerId);
return;
}
// РЕФАКТОРИНГ: MutationObserver УДАЛЕН
// Он дублировал обработку сообщений с событием chat:message-added
// Теперь используется ТОЛЬКО событийная модель - chat-detail-enhanced.js
// слушает chat:message-added и вызывает attachToMessage
console.log('[MessageContextMenu] Observer setup skipped - using event-based model only');
}
/**
* Подключить обработчики к сообщению
*/
attachToMessage(messageElement) {
const messageId = messageElement.dataset.messageId;
const authorId = parseInt(messageElement.dataset.authorId);
const isOwn = authorId === this.currentUserId;
console.log('[MessageContextMenu] attachToMessage called:', {
messageId,
authorId,
currentUserId: this.currentUserId,
isOwn,
alreadyAttached: messageElement.dataset.contextMenuAttached
});
// Десктоп: правый клик
messageElement.addEventListener('contextmenu', (e) => {
console.log('[MessageContextMenu] *** CONTEXTMENU EVENT FIRED ***');
console.log('[MessageContextMenu] Event details:', {
clientX: e.clientX,
clientY: e.clientY,
target: e.target.tagName,
messageId
});
e.preventDefault();
this.showMenu(messageElement, messageId, isOwn, e.clientX, e.clientY);
});
console.log('[MessageContextMenu] Contextmenu listener attached to message:', messageId);
// Мобилка: долгое нажатие
let touchStartX, touchStartY;
messageElement.addEventListener('touchstart', (e) => {
console.log('[MessageContextMenu] Touchstart event on message:', messageId);
touchStartX = e.touches[0].clientX;
touchStartY = e.touches[0].clientY;
this.longPressTimer = setTimeout(() => {
console.log('[MessageContextMenu] Long press triggered on message:', messageId);
// Вибрация для тактильной обратной связи
if (navigator.vibrate) {
navigator.vibrate(50);
}
this.showMenu(messageElement, messageId, isOwn, touchStartX, touchStartY);
}, this.longPressDuration);
}, { passive: true });
messageElement.addEventListener('touchmove', (e) => {
// Отменяем долгое нажатие если палец двигается
const touch = e.touches[0];
const moveX = Math.abs(touch.clientX - touchStartX);
const moveY = Math.abs(touch.clientY - touchStartY);
if (moveX > 10 || moveY > 10) {
console.log('[MessageContextMenu] Touch moved, cancelling long press');
clearTimeout(this.longPressTimer);
}
}, { passive: true });
messageElement.addEventListener('touchend', () => {
console.log('[MessageContextMenu] Touch ended');
clearTimeout(this.longPressTimer);
}, { passive: true });
messageElement.addEventListener('touchcancel', () => {
console.log('[MessageContextMenu] Touch cancelled');
clearTimeout(this.longPressTimer);
}, { passive: true });
console.log('[MessageContextMenu] All event listeners attached to message:', messageId);
}
/**
* Показать контекстное меню
*/
showMenu(messageElement, messageId, isOwn, x, y) {
console.log('[MessageContextMenu] showMenu called:', {
messageId,
isOwn,
x,
y,
messageElement: messageElement?.tagName
});
// Убрать подсветку с предыдущего сообщения (если было)
this.removeHighlight();
// Закрыть предыдущее меню
this.closeMenu();
// Создать меню
console.log('[MessageContextMenu] Creating menu...');
const menu = this.createMenu(messageElement, messageId, isOwn);
console.log('[MessageContextMenu] Menu created, appending to body');
document.body.appendChild(menu);
this.activeMenu = menu;
console.log('[MessageContextMenu] Menu appended to DOM');
// Добавить подсветку НОВОГО сообщения
console.log('[MessageContextMenu] Adding highlight to message');
messageElement.classList.add('message-highlighted');
this.highlightedMessage = messageElement; // Сохраняем ссылку
// Позиционировать меню
console.log('[MessageContextMenu] Positioning menu');
this.positionMenu(menu, x, y);
// Анимация появления
requestAnimationFrame(() => {
console.log('[MessageContextMenu] Adding show class for animation');
menu.classList.add('show');
});
}
/**
* Создать HTML меню
*/
createMenu(messageElement, messageId, isOwn) {
console.log('[MessageContextMenu] createMenu:', { messageId, isOwn });
const menu = document.createElement('div');
menu.className = 'message-context-menu';
menu.dataset.messageId = messageId;
console.log('[MessageContextMenu] Menu element created with class:', menu.className);
// Секция быстрых реакций
console.log('[MessageContextMenu] Creating reactions HTML with', this.emojis.length, 'emojis');
const reactionsHtml = this.emojis.map(emoji => `
<button class="quick-reaction-btn" data-emoji="${emoji}" data-action="react">
${emoji}
</button>
`).join('');
// Секция действий
const actionsHtml = `
<div class="menu-actions">
<button class="menu-action" data-action="reply">
<i class="bi bi-reply"></i>
<span>Ответить</span>
</button>
${isOwn ? `
<button class="menu-action" data-action="edit">
<i class="bi bi-pencil"></i>
<span>Редактировать</span>
</button>
` : ''}
<button class="menu-action" data-action="forward">
<i class="bi bi-share"></i>
<span>Переслать</span>
</button>
<button class="menu-action" data-action="copy">
<i class="bi bi-clipboard"></i>
<span>Копировать</span>
</button>
<button class="menu-action" data-action="select">
<i class="bi bi-check-square"></i>
<span>Выбрать</span>
</button>
${isOwn ? `
<button class="menu-action menu-action-danger" data-action="delete">
<i class="bi bi-trash"></i>
<span>Удалить</span>
</button>
` : ''}
</div>
`;
console.log('[MessageContextMenu] Setting innerHTML...');
menu.innerHTML = `
<div class="quick-reactions">
${reactionsHtml}
</div>
${actionsHtml}
`;
console.log('[MessageContextMenu] innerHTML set successfully');
// Обработчики
console.log('[MessageContextMenu] Attaching click handler to menu');
menu.addEventListener('click', (e) => {
const btn = e.target.closest('[data-action]');
if (!btn) {
console.log('[MessageContextMenu] Click outside action button');
return;
}
e.preventDefault();
e.stopPropagation();
const action = btn.dataset.action;
const emoji = btn.dataset.emoji;
console.log('[MessageContextMenu] Action button clicked:', action, emoji);
this.handleAction(action, messageElement, messageId, emoji);
this.removeHighlight(); // Убираем подсветку при выборе действия
this.closeMenu();
});
console.log('[MessageContextMenu] Menu fully constructed');
return menu;
}
/**
* Позиционировать меню на экране
*/
positionMenu(menu, x, y) {
console.log('[MessageContextMenu] positionMenu called:', { x, y });
const menuRect = menu.getBoundingClientRect();
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
console.log('[MessageContextMenu] Menu dimensions:', {
width: menuRect.width,
height: menuRect.height,
viewportWidth,
viewportHeight
});
let left = x;
let top = y;
// Не выходим за правый край
if (left + menuRect.width > viewportWidth) {
left = viewportWidth - menuRect.width - 10;
console.log('[MessageContextMenu] Adjusted left to prevent overflow:', left);
}
// Не выходим за левый край
if (left < 10) {
left = 10;
console.log('[MessageContextMenu] Adjusted left to prevent underflow:', left);
}
// Не выходим за нижний край
if (top + menuRect.height > viewportHeight) {
top = y - menuRect.height;
console.log('[MessageContextMenu] Adjusted top to prevent bottom overflow:', top);
}
// Не выходим за верхний край
if (top < 10) {
top = 10;
console.log('[MessageContextMenu] Adjusted top to prevent top overflow:', top);
}
console.log('[MessageContextMenu] Final position:', { left, top });
menu.style.left = `${left}px`;
menu.style.top = `${top}px`;
}
/**
* Обработать действие
*/
handleAction(action, messageElement, messageId, emoji) {
console.log('[ContextMenu] Action:', action, 'Message:', messageId);
switch (action) {
case 'react':
this.handleReact(messageId, emoji);
break;
case 'reply':
this.handleReply(messageElement);
break;
case 'edit':
this.handleEdit(messageElement);
break;
case 'delete':
this.handleDelete(messageId);
break;
case 'forward':
this.handleForward(messageId);
break;
case 'copy':
this.handleCopy(messageElement);
break;
case 'select':
this.handleSelect(messageElement, messageId);
break;
}
}
/**
* Добавить реакцию
*/
async handleReact(messageId, emoji) {
console.log('[ContextMenu] Adding reaction:', emoji, 'to message:', messageId);
try {
await this.onReactionSelect(messageId, emoji);
} catch (error) {
console.error('[ContextMenu] Failed to add reaction:', error);
this.showToast('Не удалось добавить реакцию');
}
}
/**
* Ответить на сообщение
*/
handleReply(messageElement) {
console.log('[MessageContextMenu] handleReply called');
const bubble = messageElement.querySelector('.bubble');
if (!bubble) {
console.warn('[MessageContextMenu] Bubble not found in message element');
return;
}
// Получаем данные сообщения
const messageText = bubble.textContent.trim();
const authorName = messageElement.querySelector('.small.text-secondary')?.textContent.split('·')[0]?.trim() || 'Пользователь';
const messageId = messageElement.dataset.messageId;
console.log('[MessageContextMenu] Reply data:', {
messageId,
authorName,
messageTextLength: messageText.length
});
// Используем chatFormManager если доступен
if (window.chatFormManager) {
console.log('[MessageContextMenu] Using chatFormManager.setModeToReply');
window.chatFormManager.setModeToReply(messageId, authorName, messageText);
} else {
console.warn('[MessageContextMenu] chatFormManager not available, using fallback');
// Фоллбэк на старую логику (на случай если модуль не загружен)
const textarea = document.querySelector('#id_content');
if (textarea) {
textarea.focus();
this.showToast(`Ответ на сообщение от ${authorName}`);
} else {
console.error('[MessageContextMenu] Textarea #id_content not found');
}
}
}
/**
* Редактировать сообщение
*/
handleEdit(messageElement) {
console.log('[MessageContextMenu] handleEdit called');
const bubble = messageElement.querySelector('.bubble');
if (!bubble) {
console.warn('[MessageContextMenu] Bubble not found in message element');
return;
}
// Получаем текст сообщения (без вложений и других элементов)
let messageText = '';
bubble.childNodes.forEach(node => {
if (node.nodeType === Node.TEXT_NODE) {
messageText += node.textContent;
} else if (node.nodeName === 'BR') {
messageText += '\n';
}
});
const messageId = messageElement.dataset.messageId;
console.log('[MessageContextMenu] Edit data:', {
messageId,
messageTextLength: messageText.length
});
// Используем chatFormManager если доступен
if (window.chatFormManager) {
console.log('[MessageContextMenu] Using chatFormManager.setModeToEdit');
window.chatFormManager.setModeToEdit(messageId, messageText.trim());
} else {
console.warn('[MessageContextMenu] chatFormManager not available, using fallback');
// Фоллбэк на старую логику
const textarea = document.querySelector('#id_content');
if (textarea) {
textarea.value = messageText.trim();
textarea.focus();
this.showToast('Редактирование сообщения');
} else {
console.error('[MessageContextMenu] Textarea #id_content not found');
}
}
}
/**
* Удалить сообщение
*/
async handleDelete(messageId) {
console.log('[MessageContextMenu] handleDelete called:', messageId);
if (!confirm('Удалить это сообщение?')) {
console.log('[MessageContextMenu] Delete cancelled by user');
return;
}
try {
const csrfToken = this.getCsrfToken();
const url = `/api/v1/communications/messages/${messageId}/delete/`;
console.log('[MessageContextMenu] Sending DELETE request to:', url);
console.log('[MessageContextMenu] CSRF token:', csrfToken ? 'present' : 'missing');
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken
}
});
console.log('[MessageContextMenu] Response status:', response.status);
console.log('[MessageContextMenu] Response ok:', response.ok);
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const data = await response.json();
console.log('[MessageContextMenu] Response data:', data);
if (data.ok) {
console.log('[MessageContextMenu] Delete successful');
console.log('[MessageContextMenu] NOTE: DOM will be updated via WebSocket');
// НЕ удаляем из DOM здесь - это сделает WebSocket handler
// Так все пользователи увидят удаление в реальном времени
this.showToast('Сообщение удалено');
console.log('[MessageContextMenu] Toast shown');
} else {
throw new Error(data.error || 'Failed to delete message');
}
} catch (error) {
console.error('[MessageContextMenu] Failed to delete message:', error);
this.showToast('Не удалось удалить сообщение');
}
}
/**
* Переслать сообщение
*/
handleForward(messageId) {
console.log('[ContextMenu] Forward message:', messageId);
// Отправляем событие для активации режима пересылки одного сообщения
window.dispatchEvent(new CustomEvent('message-forward-single', {
detail: { messageId }
}));
this.showToast('Выберите чат для пересылки');
}
/**
* Копировать текст сообщения
*/
async handleCopy(messageElement) {
console.log('[ContextMenu] Copy message text');
const bubble = messageElement.querySelector('.bubble');
if (!bubble) {
console.warn('[ContextMenu] Bubble not found');
return;
}
// Получаем только текстовое содержимое
let text = '';
bubble.childNodes.forEach(node => {
if (node.nodeType === Node.TEXT_NODE) {
text += node.textContent;
} else if (node.nodeName === 'BR') {
text += '\n';
} else if (node.classList && !node.classList.contains('message-attachments') && !node.classList.contains('poll-widget')) {
text += node.textContent;
}
});
text = text.trim();
try {
await navigator.clipboard.writeText(text);
this.showToast('Текст скопирован');
} catch (err) {
console.error('[ContextMenu] Failed to copy text:', err);
this.showToast('Не удалось скопировать');
}
}
/**
* Выбрать сообщение
*/
handleSelect(messageElement, messageId) {
console.log('[ContextMenu] Select message:', messageId);
// Отправляем событие для активации режима выделения
window.dispatchEvent(new CustomEvent('message-context-menu', {
detail: {
action: 'select',
messageId: messageId,
messageElement: messageElement
}
}));
}
/**
* Получить CSRF токен
*/
getCsrfToken() {
const cookie = document.cookie
.split('; ')
.find(row => row.startsWith('csrftoken='));
return cookie ? cookie.split('=')[1] : '';
}
/**
* Экранировать HTML
*/
escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
/**
* Показать временное уведомление
*/
showToast(message) {
console.log('[MessageContextMenu] showToast:', message);
const toast = document.createElement('div');
toast.className = 'context-menu-toast';
toast.textContent = message;
console.log('[MessageContextMenu] Toast created, appending to body');
document.body.appendChild(toast);
requestAnimationFrame(() => {
console.log('[MessageContextMenu] Adding show class to toast');
toast.classList.add('show');
});
setTimeout(() => {
console.log('[MessageContextMenu] Removing show class from toast');
toast.classList.remove('show');
setTimeout(() => {
console.log('[MessageContextMenu] Removing toast from DOM');
toast.remove();
}, 300);
}, 2000);
}
/**
* Убрать подсветку сообщения
*/
removeHighlight() {
if (this.highlightedMessage) {
console.log('[MessageContextMenu] Removing highlight from message:', this.highlightedMessage.dataset.messageId);
this.highlightedMessage.classList.remove('message-highlighted');
this.highlightedMessage = null;
console.log('[MessageContextMenu] Highlight removed');
} else {
console.log('[MessageContextMenu] No highlighted message to remove');
}
}
/**
* Закрыть меню
*/
closeMenu() {
if (!this.activeMenu) {
console.log('[MessageContextMenu] closeMenu: no active menu');
return;
}
console.log('[MessageContextMenu] Closing menu');
// Сохраняем ссылку на закрываемое меню
const menuToClose = this.activeMenu;
// Сразу обнуляем activeMenu (важно для быстрых кликов!)
this.activeMenu = null;
// Анимация закрытия
console.log('[MessageContextMenu] Removing show class for fade out');
menuToClose.classList.remove('show');
setTimeout(() => {
console.log('[MessageContextMenu] Removing menu from DOM');
menuToClose.remove();
}, 200);
}
}
export default MessageContextMenu;