новый функционал для редактирования сообщений

This commit is contained in:
Professional 2025-05-22 00:28:14 +07:00
parent 7146611dc3
commit e147959928
5 changed files with 255 additions and 34 deletions

View File

@ -289,10 +289,90 @@ const deleteMessage = async (req, res, next) => {
}
};
// @desc Редактировать сообщение
// @route PUT /api/conversations/:conversationId/messages/:messageId
// @access Private
const editMessage = async (req, res, next) => {
const { conversationId, messageId } = req.params;
const { text } = req.body;
const currentUserId = req.user._id;
if (!text || text.trim() === '') {
const error = new Error('Текст сообщения не может быть пустым.');
error.statusCode = 400;
return next(error);
}
try {
const conversation = await Conversation.findById(conversationId);
if (!conversation) {
const error = new Error('Диалог не найден.');
error.statusCode = 404;
return next(error);
}
if (!conversation.participants.includes(currentUserId)) {
const error = new Error('Вы не являетесь участником этого диалога.');
error.statusCode = 403;
return next(error);
}
const message = await Message.findById(messageId);
if (!message) {
const error = new Error('Сообщение не найдено.');
error.statusCode = 404;
return next(error);
}
if (!message.sender.equals(currentUserId)) {
const error = new Error('Вы можете редактировать только свои сообщения.');
error.statusCode = 403;
return next(error);
}
// Ограничение по времени на редактирование (например, 24 часа)
// const twentyFourHours = 24 * 60 * 60 * 1000;
// if (new Date() - new Date(message.createdAt) > twentyFourHours) {
// const error = new Error('Сообщение не может быть отредактировано по истечении 24 часов.');
// error.statusCode = 403;
// return next(error);
// }
message.text = text.trim();
message.isEdited = true;
message.editedAt = new Date();
const updatedMessage = await message.save();
// Загружаем данные отправителя для ответа клиенту
await updatedMessage.populate('sender', 'name photos _id');
// Эмитим событие редактирования сообщения в комнату диалога
const io = req.app.get('io');
if (io) {
io.to(conversationId).emit('messageEdited', {
message: updatedMessage.toObject(), // Отправляем обновленное сообщение целиком
conversationId,
});
console.log(`[CONV_CTRL] Emitted messageEdited to room ${conversationId} for message ${messageId}`);
} else {
console.warn('[CONV_CTRL] Socket.io instance (io) not found on req.app. Could not emit messageEdited.');
}
res.status(200).json(updatedMessage);
} catch (error) {
console.error(`[CONV_CTRL] Ошибка при редактировании сообщения ${messageId} из диалога ${conversationId}:`, error.message);
next(error);
}
};
module.exports = {
createOrGetConversation,
getUserConversations,
getMessagesForConversation,
deleteMessage, // <-- Добавляем новый метод
deleteMessage,
editMessage, // <-- Добавляем новый метод
};

View File

@ -26,6 +26,13 @@ const messageSchema = new mongoose.Schema(
type: mongoose.Schema.Types.ObjectId,
ref: 'User'
}],
isEdited: {
type: Boolean,
default: false,
},
editedAt: {
type: Date,
},
// Или просто флаг для 1-на-1 чата
// isRead: {
// type: Boolean,

View File

@ -5,7 +5,8 @@ const {
createOrGetConversation,
getUserConversations,
getMessagesForConversation,
deleteMessage // Импортируем новый контроллер
deleteMessage, // Импортируем новый контроллер
editMessage // Импортируем контроллер редактирования
} = require('../controllers/conversationController');
const { protect } = require('../middleware/authMiddleware');
@ -23,4 +24,9 @@ router.route('/:conversationId/messages')
router.route('/:conversationId/messages/:messageId')
.delete(deleteMessage); // DELETE /api/conversations/:conversationId/messages/:messageId (удалить сообщение)
// Маршрут для редактирования сообщения
router.route('/:conversationId/messages/:messageId')
.put(editMessage); // PUT /api/conversations/:conversationId/messages/:messageId (редактировать сообщение)
module.exports = router;

View File

@ -103,5 +103,8 @@ export default {
},
deleteMessage(conversationId, messageId) {
return apiClient.delete(`/conversations/${conversationId}/messages/${messageId}`);
},
editMessage(conversationId, messageId, newText) {
return apiClient.put(`/conversations/${conversationId}/messages/${messageId}`, { text: newText });
}
};

View File

@ -33,6 +33,7 @@
<p class="mb-0">{{ item.data.text }}</p>
<small class="message-timestamp text-muted">
{{ formatMessageTimestamp(item.data.createdAt) }}
<span v-if="item.data.isEdited && item.data.sender?._id === currentUser?._id" class="ms-1 fst-italic">(изменено)</span>
<span v-if="item.data.sender?._id === currentUser?._id && item.data.isSending" class="ms-1 fst-italic">(отправка...)</span>
<span v-if="item.data.sender?._id === currentUser?._id && !item.data.isSending" class="ms-1">
<i :class="getMessageStatusIcon(item.data)"></i>
@ -41,12 +42,18 @@
</div>
</div>
<!-- Кнопка удаления для ПК (появляется при наведении) -->
<button v-if="!isTouchDevice.value && hoveredMessageId === item.data._id && item.data.sender?._id === currentUser?._id"
<button v-if="!isTouchDevice.value && hoveredMessageId === item.data._id && item.data.sender?._id === currentUser?._id && !editingMessageId"
@click="confirmDeleteMessage(item.data._id)"
class="btn btn-sm btn-danger delete-message-btn"
title="Удалить сообщение">
<i class="bi bi-trash"></i>
</button>
<button v-if="!isTouchDevice.value && hoveredMessageId === item.data._id && item.data.sender?._id === currentUser?._id && !editingMessageId"
@click="startEditMessage(item.data._id)"
class="btn btn-sm btn-info edit-message-btn"
title="Редактировать сообщение">
<i class="bi bi-pencil-fill"></i>
</button>
</div>
</template>
</div>
@ -57,6 +64,10 @@
class="context-menu"
:style="{ top: contextMenu.y + 'px', left: contextMenu.x + 'px' }">
<ul>
<li @click="startEditMessage(contextMenu.messageId)">
<i class="bi bi-pencil-fill"></i>
<span>Редактировать</span>
</li>
<li @click="confirmDeleteMessage(contextMenu.messageId)">
<i class="bi bi-trash"></i>
<span>Удалить сообщение</span>
@ -70,17 +81,31 @@
</div>
<div v-if="isAuthenticated" class="message-input-area p-3 border-top bg-light">
<form @submit.prevent="sendMessage" class="d-flex">
<form @submit.prevent="editingMessageId ? saveEditedMessage() : sendMessage()" class="d-flex">
<input
type="text"
v-model="newMessageText"
class="form-control me-2"
placeholder="Введите сообщение..."
:disabled="sendingMessage"
:placeholder="editingMessageId ? 'Редактирование сообщения...' : 'Введите сообщение...'"
:disabled="sendingMessage || savingEdit"
ref="messageInputRef"
/>
<button type="submit" class="btn btn-primary" :disabled="sendingMessage || !newMessageText.trim()">
<span v-if="sendingMessage" class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
Отправить
<button
type="submit"
class="btn btn-primary"
:disabled="sendingMessage || savingEdit || !newMessageText.trim()"
>
<span v-if="sendingMessage || savingEdit" class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
{{ editingMessageId ? 'Сохранить' : 'Отправить' }}
</button>
<button
v-if="editingMessageId"
type="button"
class="btn btn-secondary ms-2"
@click="cancelEditMessage"
:disabled="savingEdit"
>
Отмена
</button>
</form>
</div>
@ -175,6 +200,13 @@ const loadingInitialMessages = ref(true);
const sendingMessage = ref(false);
const error = ref('');
// Для редактирования сообщений
const editingMessageId = ref(null);
const originalMessageText = ref('');
const savingEdit = ref(false);
const messageInputRef = ref(null);
let socket = null;
const otherUserIsTyping = ref(false); // Для отображения индикатора, что собеседник печатает
let stopTypingEmitTimer = null; // Новое имя таймера для отправки stopTyping
@ -277,7 +309,7 @@ const getOtherParticipantName = () => getOtherParticipant()?.name;
const sendMessage = () => {
if (!newMessageText.value.trim() || !socket || !currentUser.value || sendingMessage.value) return;
if (!newMessageText.value.trim() || !socket || !currentUser.value || sendingMessage.value || editingMessageId.value) return;
const otherParticipant = getOtherParticipant(); // Нужно получить ID получателя!
// Это слабое место, если conversationData не загружено правильно.
@ -317,6 +349,7 @@ const sendMessage = () => {
// Пока просто сбросим через некоторое время или после получения своего же сообщения
// setTimeout(() => { sendingMessage.value = false; }, 1000);
newMessageText.value = ''; // Очищаем поле ввода
if (messageInputRef.value) messageInputRef.value.focus(); // Возвращаем фокус в поле ввода
};
const handleNewMessage = (message) => {
@ -344,6 +377,82 @@ const handleMessageDeletedBySocket = (data) => {
}
};
// --- НОВЫЕ ФУНКЦИИ И ЛОГИКА ДЛЯ РЕДАКТИРОВАНИЯ ---
const startEditMessage = (messageId) => {
const messageToEdit = messages.value.find(msg => msg._id === messageId);
if (messageToEdit && messageToEdit.sender?._id === currentUser.value?._id) {
editingMessageId.value = messageId;
originalMessageText.value = messageToEdit.text;
newMessageText.value = messageToEdit.text;
contextMenu.value.visible = false;
if (messageInputRef.value) messageInputRef.value.focus();
console.log('[ChatView] Начало редактирования сообщения:', messageId);
}
};
const cancelEditMessage = () => {
editingMessageId.value = null;
newMessageText.value = '';
originalMessageText.value = '';
console.log('[ChatView] Редактирование отменено');
};
const saveEditedMessage = async () => {
if (!editingMessageId.value || !newMessageText.value.trim() || savingEdit.value) return;
const newText = newMessageText.value.trim();
if (newText === originalMessageText.value) {
// Текст не изменился, просто отменяем редактирование
cancelEditMessage();
return;
}
savingEdit.value = true;
error.value = '';
try {
console.log(`[ChatView] Сохранение отредактированного сообщения ${editingMessageId.value} с текстом: "${newText}"`);
// API запрос на сервер для обновления сообщения
const updatedMessage = await api.editMessage(conversationId.value, editingMessageId.value, newText);
// Обновляем сообщение в локальном массиве messages.value
// Сервер должен вернуть обновленное сообщение, включая isEdited и editedAt
const index = messages.value.findIndex(msg => msg._id === editingMessageId.value);
if (index !== -1) {
messages.value[index] = { ...messages.value[index], ...updatedMessage.data };
console.log('[ChatView] Сообщение локально обновлено:', messages.value[index]);
}
cancelEditMessage(); // Сбрасываем состояние редактирования
// Фокус вернется в sendMessage или останется, если пользователь кликнул куда-то еще
} catch (err) {
console.error('[ChatView] Ошибка при сохранении отредактированного сообщения:', err);
error.value = err.response?.data?.message || 'Не удалось сохранить изменения.';
} finally {
savingEdit.value = false;
}
};
// Обработчик события отредактированного сообщения от сокета
const handleMessageEditedBySocket = ({ message: editedMessage, conversationId: convId }) => {
console.log('[ChatView] Получено событие messageEdited от сокета:', editedMessage);
if (convId === conversationId.value) {
const index = messages.value.findIndex(msg => msg._id === editedMessage._id);
if (index !== -1) {
// Обновляем существующее сообщение новыми данными
// Важно сохранить любые локальные свойства, если они есть и не приходят с сервера
messages.value[index] = { ...messages.value[index], ...editedMessage };
console.log('[ChatView] Сообщение обновлено через сокет:', messages.value[index]);
} else {
// Если по какой-то причине сообщения нет локально, но это маловероятно для редактирования
console.warn('[ChatView] Получено отредактированное сообщение, которое не найдено локально:', editedMessage._id);
// messages.value.push(editedMessage); // Можно добавить, если такая ситуация возможна
// scrollToBottom();
}
}
};
// --- КОНЕЦ ЛОГИКИ РЕДАКТИРОВАНИЯ ---
const confirmDeleteMessage = (messageId) => {
// Логика подтверждения удаления (например, показать модальное окно)
// А затем, если пользователь подтвердил, вызвать deleteMessage
@ -378,18 +487,11 @@ const deleteMessage = async (messageId) => {
}
};
// Обработка события, что наши сообщения прочитаны собеседником
const handleMessagesReadByOther = ({ conversationId: readConversationId, readerId }) => {
if (readConversationId === conversationId.value) {
console.log(`[ChatView] Сообщения в диалоге ${readConversationId} прочитаны пользователем ${readerId}`);
// Обновляем статус сообщений, отправленных текущим пользователем
messages.value = messages.value.map(msg => {
if (msg.sender?._id === currentUser.value?._id) {
// Если сообщение отправлено нами и еще не имеет этого readerId в readBy
// (на случай если readBy не пришло сразу с сообщением или было обновлено)
// Это упрощенный вариант. В идеале, сервер должен присылать обновленные сообщения.
// Но для отображения галочек, достаточно знать, что ОДИН получатель (собеседник) прочитал.
// Мы просто добавляем readerId в readBy для наших сообщений, если его там нет.
const updatedReadBy = msg.readBy ? [...msg.readBy] : [];
if (!updatedReadBy.includes(readerId)) {
updatedReadBy.push(readerId);
@ -401,7 +503,6 @@ const handleMessagesReadByOther = ({ conversationId: readConversationId, readerI
}
};
// Следим за видимостью вкладки для отметки сообщений как прочитанных
const handleVisibilityChange = () => {
if (document.visibilityState === 'visible') {
markMessagesAsReadOnServer();
@ -424,34 +525,28 @@ watch(newMessageText, (newValue /*, oldValue */) => { // oldValue больше
senderId: currentUser.value._id
};
// Всегда очищаем таймер stopTyping при любом изменении текста
clearTimeout(stopTypingEmitTimer);
if (newValue.trim().length > 0) {
// Пользователь печатает (поле не пустое)
if (!userHasSentTypingEvent.value) {
// Если событие 'typing' еще не было отправлено для этой "серии" набора
if (socket.connected) { // Убедимся, что сокет все еще подключен
if (socket.connected) {
socket.emit('typing', payload);
userHasSentTypingEvent.value = true;
console.log('[ChatView] Emit typing (new burst)');
}
}
// Устанавливаем таймер для отправки 'stopTyping', если пользователь перестанет печатать
stopTypingEmitTimer = setTimeout(() => {
if (socket.connected) { // Убедимся, что сокет все еще подключен
if (socket.connected) {
socket.emit('stopTyping', payload);
userHasSentTypingEvent.value = false; // Сбрасываем для следующей "серии" набора
userHasSentTypingEvent.value = false;
console.log('[ChatView] Emit stopTyping (timeout)');
}
}, TYPING_TIMER_LENGTH);
} else {
// Пользователь очистил поле ввода или оно стало пустым
if (userHasSentTypingEvent.value) {
// Если событие 'typing' было активно, отправляем 'stopTyping'
if (socket.connected) { // Убедимся, что сокет все еще подключен
if (socket.connected) {
socket.emit('stopTyping', payload);
userHasSentTypingEvent.value = false;
console.log('[ChatView] Emit stopTyping (cleared input)');
@ -490,6 +585,7 @@ onMounted(async () => {
}
});
socket.on('messageDeleted', handleMessageDeletedBySocket);
socket.on('messageEdited', handleMessageEditedBySocket); // <--- Подписываемся на событие редактирования
if (conversationId.value) {
socket.emit('joinConversationRoom', conversationId.value);
@ -509,6 +605,7 @@ onUnmounted(() => {
socket.off('userTyping');
socket.off('userStopTyping');
socket.off('messageDeleted', handleMessageDeletedBySocket);
socket.off('messageEdited', handleMessageEditedBySocket); // <--- Отписываемся
if (conversationId.value) {
socket.emit('leaveConversationRoom', conversationId.value);
@ -526,23 +623,17 @@ const formatMessageTimestamp = (timestamp) => {
return new Date(timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
};
// Иконка статуса сообщения
const getMessageStatusIcon = (message) => {
if (message.isSending) {
return 'bi bi-clock'; // Отправка (часы)
}
// Для чата 1-на-1, если есть хотя бы один ID в readBy, и он не наш, значит собеседник прочитал
const otherParticipant = getOtherParticipant();
if (otherParticipant && message.readBy && message.readBy.includes(otherParticipant._id)) {
return 'bi bi-check2-all'; // Прочитано (две галочки)
}
// Если сообщение доставлено (т.е. оно есть на сервере и не isSending), но не прочитано собеседником
// (или readBy пуст/не содержит ID собеседника)
return 'bi bi-check2'; // Доставлено (одна галочка)
// Для групповых чатов логика была бы сложнее
};
// НОВАЯ ФУНКЦИЯ для обработки нативного события contextmenu
const handleNativeContextMenu = (event, message) => {
if (message.sender?._id === currentUser.value?._id) {
event.preventDefault(); // Prevent native context menu for own messages to show custom one
@ -626,6 +717,10 @@ const handleClickOutsideContextMenu = (event) => {
margin-left: 8px;
order: 1; /* Кнопка справа от сообщения */
}
.message-item-wrapper.sent-wrapper .edit-message-btn {
margin-left: 4px; /* Меньше отступ чем у кнопки удаления */
order: 0; /* Кнопка редактирования левее кнопки удаления */
}
.message-item-wrapper.received-wrapper {
justify-content: flex-start;
@ -651,6 +746,26 @@ const handleClickOutsideContextMenu = (event) => {
vertical-align: middle;
}
.edit-message-btn {
align-self: center;
opacity: 0.6;
transition: opacity 0.2s ease-in-out;
padding: 0.2rem 0.4rem;
font-size: 0.8rem;
line-height: 1;
background: transparent;
border: none;
color: #0dcaf0; /* Bootstrap info color */
}
.edit-message-btn:hover,
.edit-message-btn:focus {
opacity: 1;
background: rgba(13, 202, 240, 0.1);
}
.edit-message-btn i {
vertical-align: middle;
}
/* Стили для контекстного меню */
.context-menu {
position: fixed;
@ -690,5 +805,15 @@ const handleClickOutsideContextMenu = (event) => {
color: #a71d2a; /* Darker danger color on hover */
}
.context-menu li i.bi-pencil-fill {
margin-right: 10px;
color: #0d6efd; /* Bootstrap primary color */
font-size: 1.0em;
}
.context-menu li:hover i.bi-pencil-fill {
color: #0a58ca; /* Darker primary color on hover */
}
/* ... other existing styles ... */
</style>