Изменения: - Новый 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 с задачей очистки
1011 lines
42 KiB
JavaScript
1011 lines
42 KiB
JavaScript
// 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;
|