Corp/backend/static/js/components/messageContextMenu.js
Игорь Надеин fa0948c159 feat(communications): добавлена поддержка редактирования сообщений с изменением attachments
Изменения:
- Новый endpoint /api/v1/communications/messages/upload-temp/ для загрузки файлов без создания сообщения
- Поле message в MessageAttachment теперь nullable для временных вложений
- Логика перемещения attachments между сообщениями при редактировании
- Frontend использует upload-temp вместо создания временных сообщений
- Celery задача cleanup_orphaned_attachments для очистки забытых файлов (каждый час)
- Исправлена проблема с WebSocket обновлениями при редактировании
- Удалены debug логи после успешного тестирования

Технические детали:
- communications/models.py: добавлено null=True, blank=True к MessageAttachment.message
- communications/migrations/0022: миграция для изменения поля
- api/v1/communications/viewsets.py: новый action upload_temp() + логика перемещения в update()
- static/js/components/chatComposer.js: использование upload-temp вместо upload
- communications/tasks.py: новая Celery задача для очистки старых orphaned attachments
- eusrr_backend/settings.py: добавлен CELERY_BEAT_SCHEDULE с задачей очистки
2026-01-15 11:13:54 +03:00

1011 lines
42 KiB
JavaScript
Raw Permalink 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 || ['👍', '❤️', '😂', '😮', '<27>']; // 5 смайликов + кнопка = 6 элементов
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') &&
!e.target.closest('.quick-reactions-panel')) {
this.removeHighlight(); // Убираем подсветку
this.closeMenu();
}
}, true); // Используем capture phase для приоритета
// Закрытие при 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) => {
// Игнорируем клик на кнопках реакций и контейнере реакций
if (e.target.closest('.reaction-button') || e.target.closest('.message-reactions')) {
console.log('[MessageContextMenu] Ignoring contextmenu on reaction button/container');
return; // Не вызываем preventDefault, позволяем событию идти дальше
}
console.log('[MessageContextMenu] *** CONTEXTMENU EVENT FIRED ***');
console.log('[MessageContextMenu] Event details:', {
clientX: e.clientX,
clientY: e.clientY,
target: e.target.tagName,
messageId
});
e.preventDefault();
e.stopPropagation(); // Предотвращаем всплытие к обработчику на document
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) => {
// Игнорируем долгое нажатие на кнопках реакций и контейнере реакций
if (e.target.closest('.reaction-button') || e.target.closest('.message-reactions')) {
console.log('[MessageContextMenu] Ignoring touchstart on reaction button/container');
return;
}
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);
// Добавляем панель реакций отдельно
if (menu.reactionsPanel) {
document.body.appendChild(menu.reactionsPanel);
console.log('[MessageContextMenu] Reactions panel appended to body');
}
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');
if (menu.reactionsPanel) {
menu.reactionsPanel.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);
// Секция быстрых реакций (Telegram-style)
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('') + `
<button class="quick-reaction-btn quick-reaction-btn-more" data-action="show-more-reactions" title="Развернуть">
<i class="bi bi-chevron-up"></i>
</button>
`;
// Секция действий
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 = actionsHtml;
console.log('[MessageContextMenu] innerHTML set successfully');
// Создаём отдельный floating элемент для быстрых реакций (как в Telegram)
const reactionsPanel = document.createElement('div');
reactionsPanel.className = 'quick-reactions-panel';
reactionsPanel.innerHTML = `
<div class="quick-reactions-wrapper">
<div class="quick-reactions-list">
${reactionsHtml}
</div>
</div>
`;
menu.reactionsPanel = reactionsPanel; // Сохраняем ссылку
console.log('[MessageContextMenu] Reactions panel created');
// Обработчик кликов на панели реакций
reactionsPanel.addEventListener('click', (e) => {
const btn = e.target.closest('[data-action]');
if (!btn) {
console.log('[MessageContextMenu] Click outside action button on reactions panel');
return;
}
e.preventDefault();
e.stopPropagation();
const action = btn.dataset.action;
const emoji = btn.dataset.emoji;
console.log('[MessageContextMenu] Reaction button clicked:', action, emoji);
// Если это кнопка "показать больше реакций" - раскрываем полный список
if (action === 'show-more-reactions') {
console.log('[MessageContextMenu] Show more reactions clicked - expanding');
this.expandReactionsList(reactionsPanel, messageElement, messageId);
return;
}
this.handleAction(action, messageElement, messageId, emoji);
this.removeHighlight();
this.closeMenu();
});
// Обработчики на основном меню
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;
const isMobile = viewportWidth <= 768;
console.log('[MessageContextMenu] Menu dimensions:', {
width: menuRect.width,
height: menuRect.height,
viewportWidth,
viewportHeight,
isMobile
});
let left, top;
// Единое позиционирование для всех устройств - рядом с курсором/касанием
left = x;
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`;
// Позиционируем панель реакций над меню (одинаково для всех устройств)
if (menu.reactionsPanel) {
const reactionsRect = menu.reactionsPanel.getBoundingClientRect();
const reactionsLeft = left;
const reactionsTop = top - reactionsRect.height - 12; // 12px gap
menu.reactionsPanel.style.left = `${reactionsLeft}px`;
menu.reactionsPanel.style.top = `${reactionsTop}px`;
}
}
/**
* Раскрыть список реакций (показать все доступные эмодзи)
*/
expandReactionsList(reactionsPanel, messageElement, messageId) {
const moreBtn = reactionsPanel.querySelector('.quick-reaction-btn-more');
const reactionsList = reactionsPanel.querySelector('.quick-reactions-list');
// Сохраняем текущую позицию и высоту
const currentTop = parseFloat(reactionsPanel.style.top);
const currentHeight = reactionsPanel.offsetHeight;
// Проверяем текущее состояние
const isExpanded = moreBtn.classList.contains('expanded');
if (isExpanded) {
// Сворачиваем - возвращаем только быстрые реакции
moreBtn.classList.remove('expanded');
const quickEmojis = this.emojis.map(emoji => `
<button class="quick-reaction-btn" data-emoji="${emoji}" data-action="react">
${emoji}
</button>
`).join('') + `
<button class="quick-reaction-btn quick-reaction-btn-more" data-action="show-more-reactions" title="Развернуть">
<i class="bi bi-chevron-up"></i>
</button>
`;
reactionsList.innerHTML = quickEmojis;
// Пересчитываем позицию после сворачивания (двигаем вниз)
requestAnimationFrame(() => {
const newHeight = reactionsPanel.offsetHeight;
const heightDiff = currentHeight - newHeight;
reactionsPanel.style.top = `${currentTop + heightDiff}px`;
});
} else {
// Раскрываем - показываем все эмодзи
moreBtn.classList.add('expanded');
const allEmojis = [
'👍', '👎', '❤️', '🔥', '😂', '😮', '😢', '😡',
'👏', '🙏', '💯', '🎉', '❓', '💪', '🤝', '👀',
'🤔', '😊', '😎', '🤗', '😴', '🥳', '😇', '🤩'
];
const expandedEmojis = allEmojis.map(emoji => `
<button class="quick-reaction-btn" data-emoji="${emoji}" data-action="react">
${emoji}
</button>
`).join('') + `
<button class="quick-reaction-btn quick-reaction-btn-more expanded" data-action="show-more-reactions" title="Свернуть">
<i class="bi bi-chevron-up"></i>
</button>
`;
reactionsList.innerHTML = expandedEmojis;
// Пересчитываем позицию после раскрытия (двигаем вверх)
requestAnimationFrame(() => {
const newHeight = reactionsPanel.offsetHeight;
const heightDiff = newHeight - currentHeight;
reactionsPanel.style.top = `${currentTop - heightDiff}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');
}
}
}
/**
* Редактировать сообщение
*/
async handleEdit(messageElement) {
console.log('[MessageContextMenu] handleEdit called');
const messageId = messageElement.dataset.messageId;
console.log('[MessageContextMenu] Message ID:', messageId);
// Получаем полные данные сообщения
let messageData = null;
// Приоритет 1: MessageStore (если сообщение уже загружено)
if (window.messageStore && typeof window.messageStore.getMessage === 'function') {
try {
messageData = window.messageStore.getMessage(messageId);
if (messageData) {
console.log('[MessageContextMenu] ✓ Message from Store:', {
id: messageData.id,
content: messageData.content?.substring(0, 50),
attachmentsCount: messageData.attachments?.length || 0
});
}
} catch (e) {
console.error('[MessageContextMenu] Store error:', e);
}
}
// Приоритет 2: ChatController V2
if (!messageData && window.chatController && typeof window.chatController.getMessage === 'function') {
try {
messageData = window.chatController.getMessage(messageId);
if (messageData) {
console.log('[MessageContextMenu] ✓ Message from ChatController');
}
} catch (e) {
console.error('[MessageContextMenu] ChatController error:', e);
}
}
// Fallback: Извлекаем из DOM (теряется Markdown и нет файлов)
if (!messageData) {
console.warn('[MessageContextMenu] Message not found in Store, using DOM fallback');
const bubble = messageElement.querySelector('.bubble');
const messageContent = messageElement.querySelector('.message-content');
console.log('[MessageContextMenu] DOM elements found:', {
hasBubble: !!bubble,
hasMessageContent: !!messageContent,
bubbleHTML: bubble?.innerHTML.substring(0, 200),
messageContentHTML: messageContent?.innerHTML.substring(0, 200)
});
if (!bubble && !messageContent) {
console.error('[MessageContextMenu] Neither .bubble nor .message-content found!');
return;
}
// Извлекаем текст - приоритет .message-content, fallback на .bubble
const sourceElement = messageContent || bubble;
let messageText = '';
// Более аккуратное извлечение текста
const extractText = (element) => {
let text = '';
element.childNodes.forEach(node => {
if (node.nodeType === Node.TEXT_NODE) {
text += node.textContent;
} else if (node.nodeName === 'BR') {
text += '\n';
} else if (node.nodeType === Node.ELEMENT_NODE) {
// Рекурсивно извлекаем текст из вложенных элементов
// НО игнорируем системные элементы
if (!node.classList.contains('edited-indicator') &&
!node.classList.contains('message-time') &&
!node.classList.contains('message-attachments') &&
!node.classList.contains('message-reactions')) {
text += extractText(node);
}
}
});
return text;
};
messageText = extractText(sourceElement).trim();
console.log('[MessageContextMenu] Extracted text:', {
length: messageText.length,
preview: messageText.substring(0, 100)
});
// Извлекаем вложения из DOM
const attachments = [];
const attachmentsContainer = messageElement.querySelector('.message-attachments');
console.log('[MessageContextMenu] DOM: Searching for attachments...', {
hasContainer: !!attachmentsContainer,
messageElement: messageElement
});
if (attachmentsContainer) {
// Ищем все типы вложений: .attachment-link (файлы) и .attachment-item (медиа)
const attachmentElements = attachmentsContainer.querySelectorAll('.attachment-link, .attachment-item');
console.log('[MessageContextMenu] DOM: Found attachments:', attachmentElements.length);
attachmentElements.forEach((element, index) => {
let fileUrl, fileName, fileSize = '';
if (element.classList.contains('attachment-link')) {
// Обычные файлы (документы, аудио)
fileUrl = element.getAttribute('href');
fileName = element.querySelector('.attachment-name')?.textContent || `Файл ${index + 1}`;
fileSize = element.querySelector('.attachment-size')?.textContent || '';
} else if (element.classList.contains('attachment-item')) {
// Медиа файлы (изображения, видео)
const linkElement = element.tagName === 'A' ? element : element.closest('a');
const imgElement = element.querySelector('img');
const videoElement = element.querySelector('video');
if (linkElement) {
fileUrl = linkElement.getAttribute('href');
} else if (imgElement) {
fileUrl = imgElement.getAttribute('src');
} else if (videoElement) {
fileUrl = videoElement.getAttribute('src');
}
if (imgElement) {
fileName = imgElement.getAttribute('alt') || `Изображение ${index + 1}`;
} else if (videoElement) {
fileName = `Видео ${index + 1}`;
} else {
fileName = `Медиа ${index + 1}`;
}
}
if (!fileUrl) {
console.warn('[MessageContextMenu] Skipping attachment without URL:', element);
return;
}
// Извлекаем ID из data-атрибута (приоритет) или из URL (fallback)
let attachmentId = element.getAttribute('data-attachment-id');
if (!attachmentId) {
// Fallback: пробуем извлечь из URL (может не сработать)
console.warn('[MessageContextMenu] No data-attachment-id, trying URL extraction');
attachmentId = `url-${fileUrl.split('/').pop().split('.')[0]}-${index}`;
}
console.log('[MessageContextMenu] ID extraction:', { fileUrl, dataAttrId: element.getAttribute('data-attachment-id'), attachmentId });
const attachment = {
id: attachmentId,
filename: fileName,
file_name: fileName,
file_url: fileUrl,
url: fileUrl,
size_str: fileSize
};
attachments.push(attachment);
console.log('[MessageContextMenu] ✓ Extracted attachment from DOM:', {
index,
type: element.classList.contains('attachment-link') ? 'file' : 'media',
fileName,
fileUrl,
attachmentId,
fullObject: attachment
});
});
}
messageData = {
id: messageId,
content: messageText,
attachments: attachments
};
console.log('[MessageContextMenu] Extracted from DOM:', {
textLength: messageText.length,
attachmentsCount: attachments.length
});
}
console.log('[MessageContextMenu] Final message data:', {
id: messageData.id,
contentLength: messageData.content?.length || 0,
hasAttachments: !!messageData.attachments?.length,
attachmentsCount: messageData.attachments?.length || 0,
attachments: messageData.attachments // Полный список для отладки
});
// Используем chatFormManager если доступен
if (window.chatFormManager && typeof window.chatFormManager.setModeToEdit === 'function') {
console.log('[MessageContextMenu] Calling chatFormManager.setModeToEdit with:', {
id: messageData.id,
contentLength: messageData.content?.length || 0,
attachmentsCount: messageData.attachments?.length || 0,
attachments: messageData.attachments
});
window.chatFormManager.setModeToEdit(
messageData.id,
messageData.content || '',
messageData.attachments || []
);
} else {
console.error('[MessageContextMenu] chatFormManager not available or setModeToEdit not a function');
console.log('[MessageContextMenu] window.chatFormManager:', window.chatFormManager);
}
}
/**
* Удалить сообщение
*/
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}/`;
console.log('[MessageContextMenu] Sending DELETE request to:', url);
console.log('[MessageContextMenu] CSRF token:', csrfToken ? 'present' : 'missing');
const response = await fetch(url, {
method: 'DELETE',
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}`);
}
// Статус 204 = No Content, не парсим JSON
if (response.status === 204) {
console.log('[MessageContextMenu] Delete successful (204)');
console.log('[MessageContextMenu] NOTE: DOM will be updated via WebSocket');
// НЕ удаляем из DOM здесь - это сделает WebSocket handler
// Так все пользователи увидят удаление в реальном времени
this.showToast('Сообщение удалено');
console.log('[MessageContextMenu] Toast shown');
return;
}
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;
}
// Получаем только содержимое сообщения (без автора, времени, реакций)
const messageContent = bubble.querySelector('.message-content');
if (!messageContent) {
console.warn('[ContextMenu] Message content not found');
return;
}
// Копируем только чистый текст сообщения
const text = messageContent.textContent
.replace(/\(ред\.\)/, '') // Убираем метку редактирования
.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;
const reactionsToClose = menuToClose.reactionsPanel;
// Сразу обнуляем activeMenu (важно для быстрых кликов!)
this.activeMenu = null;
// Анимация закрытия
console.log('[MessageContextMenu] Removing show class for fade out');
menuToClose.classList.remove('show');
if (reactionsToClose) {
reactionsToClose.classList.remove('show');
}
setTimeout(() => {
console.log('[MessageContextMenu] Removing menu from DOM');
menuToClose.remove();
if (reactionsToClose) {
reactionsToClose.remove();
}
}, 200);
}
}
export default MessageContextMenu;