Corp/backend/static/js/components/messageReactions.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

515 lines
20 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/messageReactions.js
/**
* Компонент для работы с реакциями на сообщения
*/
export class MessageReactions {
constructor(config = {}) {
this.apiBaseUrl = config.apiBaseUrl || '/api/v1/communications/messages';
this.emojis = config.emojis || ['👍', '❤️', '😂', '😮', '😢', '🙏', '👏', '🔥'];
this.getCsrfToken = config.getCsrfToken || this.defaultGetCsrfToken;
}
defaultGetCsrfToken() {
const cookie = document.cookie
.split('; ')
.find(row => row.startsWith('csrftoken='));
return cookie ? cookie.split('=')[1] : '';
}
/**
* Добавить реакцию на сообщение
*/
async addReaction(messageId, emoji) {
console.log('[MessageReactions] Adding reaction:', { messageId, emoji });
try {
const url = `${this.apiBaseUrl}/${messageId}/react/`;
console.log('[MessageReactions] Sending POST to:', url);
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': this.getCsrfToken()
},
body: JSON.stringify({ emoji })
});
console.log('[MessageReactions] Response status:', response.status);
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const data = await response.json();
console.log('[MessageReactions] Response data:', data);
if (!data.ok) {
throw new Error(data.error || 'Failed to add reaction');
}
return data;
} catch (error) {
console.error('[MessageReactions] Error adding reaction:', error);
throw error;
}
}
/**
* Удалить свою реакцию с сообщения
*/
async removeReaction(messageId, emoji) {
console.log('[MessageReactions] Removing reaction:', { messageId, emoji });
try {
const url = `${this.apiBaseUrl}/${messageId}/unreact/`;
console.log('[MessageReactions] Sending POST to:', url);
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': this.getCsrfToken()
},
body: JSON.stringify({ emoji })
});
console.log('[MessageReactions] Response status:', response.status);
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const data = await response.json();
console.log('[MessageReactions] Response data:', data);
if (!data.ok) {
throw new Error(data.error || 'Failed to remove reaction');
}
return data;
} catch (error) {
console.error('[MessageReactions] Error removing reaction:', error);
throw error;
}
}
/**
* Получить все реакции для сообщения
*/
async getReactions(messageId) {
try {
const response = await fetch(`${this.apiBaseUrl}/${messageId}/reactions/`);
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const data = await response.json();
if (!data.ok) {
throw new Error(data.error || 'Failed to get reactions');
}
return data.reactions;
} catch (error) {
console.error('Error getting reactions:', error);
throw error;
}
}
/**
* Создать HTML для отображения реакций
* Кнопка добавления реакции убрана - реакции добавляются через контекстное меню
*/
renderReactions(reactionsSummary, currentUserId) {
const hasReactions = reactionsSummary && Object.keys(reactionsSummary).length > 0;
// Убедимся, что currentUserId это число
const userId = parseInt(currentUserId);
let reactionsHtml = '';
if (hasReactions) {
reactionsHtml = Object.entries(reactionsSummary)
.map(([emoji, data]) => {
const isUserReacted = data.users.includes(userId);
const activeClass = isUserReacted ? 'active' : '';
const title = data.user_names.join(', ');
return `
<button
class="reaction-button ${activeClass}"
data-emoji="${emoji}"
title="${title}"
>
<span class="emoji">${emoji}</span>
<span class="count">${data.count}</span>
</button>
`;
})
.join('');
}
return `
<div class="message-reactions">
${reactionsHtml}
</div>
`;
}
/**
* Инициализировать обработчики для сообщения
*/
/**
* Инициализация обработчиков реакций для сообщения
* Кнопка "Добавить реакцию" (➕) убрана - реакции добавляются через контекстное меню
*/
initMessageReactions(messageElement, messageId, currentUserId) {
const reactionsContainer = messageElement.querySelector('.message-reactions');
if (!reactionsContainer) return;
let longPressTimer = null;
let longPressTriggered = false;
// Обработчик начала нажатия (mousedown/touchstart)
const handlePressStart = (e, reactionBtn) => {
// Останавливаем всплытие чтобы не срабатывало контекстное меню сообщения
e.stopPropagation();
// НЕ вызываем e.preventDefault() чтобы клики работали на мобилках
longPressTriggered = false;
const emoji = reactionBtn.dataset.emoji;
const title = reactionBtn.getAttribute('title');
longPressTimer = setTimeout(() => {
longPressTriggered = true;
this.showReactionUsers(reactionBtn, emoji, title);
}, 500); // 500ms для долгого нажатия
};
// Обработчик окончания нажатия
const handlePressEnd = (e) => {
if (e) {
e.stopPropagation();
}
if (longPressTimer) {
clearTimeout(longPressTimer);
longPressTimer = null;
}
};
// Обработчик клика на существующую реакцию (добавить/удалить)
reactionsContainer.addEventListener('click', async (e) => {
const reactionBtn = e.target.closest('.reaction-button');
if (reactionBtn) {
e.preventDefault();
e.stopPropagation(); // Останавливаем всплытие
// Если было долгое нажатие - не выполняем клик
if (longPressTriggered) {
longPressTriggered = false;
return;
}
const emoji = reactionBtn.dataset.emoji;
const isActive = reactionBtn.classList.contains('active');
try {
if (isActive) {
// Удалить свою реакцию
await this.removeReaction(messageId, emoji);
} else {
// Добавить/изменить реакцию
await this.addReaction(messageId, emoji);
}
} catch (error) {
console.error('Reaction toggle error:', error);
}
}
});
// Обработчик правой кнопки мыши (контекстное меню)
reactionsContainer.addEventListener('contextmenu', (e) => {
console.log('[MessageReactions] contextmenu event on reactions container');
const reactionBtn = e.target.closest('.reaction-button');
if (reactionBtn) {
console.log('[MessageReactions] contextmenu on reaction button, showing users');
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation(); // Останавливаем все обработчики
const emoji = reactionBtn.dataset.emoji;
const title = reactionBtn.getAttribute('title');
this.showReactionUsers(reactionBtn, emoji, title);
}
}, true); // Используем capturing phase
// Обработчики долгого нажатия для мыши
reactionsContainer.addEventListener('mousedown', (e) => {
const reactionBtn = e.target.closest('.reaction-button');
if (reactionBtn && e.button === 0) { // Только левая кнопка
handlePressStart(e, reactionBtn);
}
});
reactionsContainer.addEventListener('mouseup', (e) => {
handlePressEnd(e);
});
reactionsContainer.addEventListener('mouseleave', (e) => {
handlePressEnd(e);
});
// Обработчики долгого нажатия для тачскрина
reactionsContainer.addEventListener('touchstart', (e) => {
console.log('[MessageReactions] touchstart event on reactions container');
const reactionBtn = e.target.closest('.reaction-button');
if (reactionBtn) {
console.log('[MessageReactions] touchstart on reaction button');
handlePressStart(e, reactionBtn);
}
}, { passive: true, capture: true }); // passive: true чтобы не блокировать скролл
reactionsContainer.addEventListener('touchend', (e) => {
const reactionBtn = e.target.closest('.reaction-button');
// Если было долгое нажатие - preventDefault чтобы не было клика
if (longPressTriggered && reactionBtn) {
e.preventDefault();
e.stopPropagation();
longPressTriggered = false;
}
handlePressEnd(e);
});
reactionsContainer.addEventListener('touchcancel', (e) => {
handlePressEnd(e);
});
}
/**
* Показать список пользователей, поставивших реакцию
*/
showReactionUsers(reactionBtn, emoji, userNames) {
console.log('[MessageReactions] showReactionUsers called', { emoji, userNames });
// Удаляем предыдущий попап если есть
this.closeReactionUsersPopup();
// Создаем попап
const popup = document.createElement('div');
popup.className = 'reaction-users-popup';
const usersList = userNames.split(', ');
console.log('[MessageReactions] Users list:', usersList);
const usersHtml = usersList.map(name => `<div class="user-name">${name}</div>`).join('');
popup.innerHTML = `
<div class="reaction-users-header">
<span class="reaction-emoji">${emoji}</span>
<span class="reaction-count">${usersList.length}</span>
</div>
<div class="reaction-users-list">
${usersHtml}
</div>
`;
document.body.appendChild(popup);
// Позиционируем попап
const rect = reactionBtn.getBoundingClientRect();
const popupRect = popup.getBoundingClientRect();
let left = rect.left + (rect.width / 2) - (popupRect.width / 2);
let top = rect.top - popupRect.height - 8;
// Корректируем если выходит за пределы экрана
if (left < 8) left = 8;
if (left + popupRect.width > window.innerWidth - 8) {
left = window.innerWidth - popupRect.width - 8;
}
if (top < 8) {
top = rect.bottom + 8; // Показываем снизу
}
popup.style.left = `${left}px`;
popup.style.top = `${top}px`;
// Сохраняем ссылку на активный попап
this.activePopup = popup;
// Анимация появления
requestAnimationFrame(() => {
popup.classList.add('show');
});
// Закрываем при клике вне попапа
const handleClickOutside = (e) => {
if (this.activePopup && !this.activePopup.contains(e.target) && !e.target.closest('.reaction-button')) {
this.closeReactionUsersPopup();
}
};
// Закрываем при нажатии Escape
const handleEscape = (e) => {
if (e.key === 'Escape' && this.activePopup) {
this.closeReactionUsersPopup();
}
};
// Закрываем при скролле
const handleScroll = () => {
if (this.activePopup) {
this.closeReactionUsersPopup();
}
};
// Сохраняем обработчики для возможности удаления
this.popupHandlers = {
click: handleClickOutside,
escape: handleEscape,
scroll: handleScroll
};
// Добавляем обработчики с небольшой задержкой чтобы не закрыть сразу
setTimeout(() => {
document.addEventListener('click', handleClickOutside);
document.addEventListener('keydown', handleEscape);
document.addEventListener('scroll', handleScroll, true);
}, 100);
}
/**
* Закрыть попап списка пользователей
*/
closeReactionUsersPopup() {
if (!this.activePopup) {
return;
}
console.log('[MessageReactions] Closing reaction users popup');
const popupToClose = this.activePopup;
this.activePopup = null;
// Удаляем обработчики событий
if (this.popupHandlers) {
document.removeEventListener('click', this.popupHandlers.click);
document.removeEventListener('keydown', this.popupHandlers.escape);
document.removeEventListener('scroll', this.popupHandlers.scroll, true);
this.popupHandlers = null;
}
// Анимация закрытия
popupToClose.classList.remove('show');
setTimeout(() => {
popupToClose.remove();
}, 200);
}
/**
* Обновить отображение реакций для сообщения
* Работает как с .message-reactions-wrapper так и с .message-reactions напрямую
*/
updateMessageReactions(messageElement, reactionsSummary, currentUserId) {
// Обновляем data-reactions атрибут
messageElement.setAttribute('data-reactions', JSON.stringify(reactionsSummary || {}));
let reactionsContainer = messageElement.querySelector('.message-reactions');
// Если нет реакций - удаляем контейнер
if (!reactionsSummary || Object.keys(reactionsSummary).length === 0) {
if (reactionsContainer) {
reactionsContainer.remove();
}
return;
}
// Рендерим HTML реакций
const reactionsHtml = this.renderReactions(reactionsSummary, currentUserId);
if (reactionsContainer) {
// Обновляем существующий контейнер
reactionsContainer.innerHTML = reactionsHtml;
} else {
// Создаем новый контейнер
const bubble = messageElement.querySelector('.bubble');
if (bubble) {
reactionsContainer = document.createElement('div');
reactionsContainer.className = 'message-reactions mt-2';
reactionsContainer.innerHTML = reactionsHtml;
// Вставляем перед временем сообщения
const timeEl = bubble.querySelector('.message-time');
if (timeEl) {
timeEl.parentNode.insertBefore(reactionsContainer, timeEl);
} else {
bubble.appendChild(reactionsContainer);
}
}
}
}
/**
* Обработать WebSocket событие добавления реакции
*/
handleReactionAdded(data, currentUserId) {
console.log('[Reactions] === handleReactionAdded START ===');
console.log('[Reactions] Event data:', JSON.stringify(data));
console.log('[Reactions] Message ID to find:', data.message_id);
console.log('[Reactions] Current user ID:', currentUserId);
const messageElements = document.querySelectorAll(`[data-message-id="${data.message_id}"]`);
console.log('[Reactions] Matched message elements count:', messageElements.length);
if (messageElements.length) {
messageElements.forEach((messageElement, idx) => {
console.log(`[Reactions] ✓ Updating element #${idx + 1}:`, messageElement);
console.log('[Reactions] Element HTML preview:', messageElement.outerHTML.substring(0, 200));
this.updateMessageReactions(
messageElement,
data.reactions_summary,
currentUserId
);
});
} else {
console.error('[Reactions] ✗ Message element NOT FOUND for ID:', data.message_id);
console.log('[Reactions] All message elements:', document.querySelectorAll('[data-message-id]'));
}
console.log('[Reactions] === handleReactionAdded END ===');
}
/**
* Обработать WebSocket событие удаления реакции
*/
handleReactionRemoved(data, currentUserId) {
console.log('[Reactions] === handleReactionRemoved START ===');
console.log('[Reactions] Event data:', JSON.stringify(data));
console.log('[Reactions] Message ID to find:', data.message_id);
console.log('[Reactions] Current user ID:', currentUserId);
const messageElements = document.querySelectorAll(`[data-message-id="${data.message_id}"]`);
console.log('[Reactions] Matched message elements count:', messageElements.length);
if (messageElements.length) {
messageElements.forEach((messageElement, idx) => {
console.log(`[Reactions] ✓ Updating element #${idx + 1}:`, messageElement);
console.log('[Reactions] Element HTML preview:', messageElement.outerHTML.substring(0, 200));
this.updateMessageReactions(
messageElement,
data.reactions_summary,
currentUserId
);
});
} else {
console.error('[Reactions] ✗ Message element NOT FOUND for ID:', data.message_id);
console.log('[Reactions] All message elements:', document.querySelectorAll('[data-message-id]'));
}
console.log('[Reactions] === handleReactionRemoved END ===');
}
}
// Экспорт для использования в других модулях
export default MessageReactions;