новый функционал для редактирования сообщений
This commit is contained in:
parent
7146611dc3
commit
e147959928
@ -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, // <-- Добавляем новый метод
|
||||
};
|
@ -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,
|
||||
|
@ -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;
|
@ -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 });
|
||||
}
|
||||
};
|
@ -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>
|
Loading…
x
Reference in New Issue
Block a user