824 lines
34 KiB
Vue
824 lines
34 KiB
Vue
<template>
|
||
<div class="chat-view-container d-flex flex-column">
|
||
<div v-if="loadingInitialMessages" class="flex-grow-1 d-flex justify-content-center align-items-center">
|
||
<div class="spinner-border text-primary" role="status">
|
||
<span class="visually-hidden">Загрузка сообщений...</span>
|
||
</div>
|
||
</div>
|
||
<div v-if="error" class="alert alert-danger m-3">{{ error }}</div>
|
||
|
||
<div v-if="!loadingInitialMessages && !conversationData" class="alert alert-warning m-3">
|
||
Не удалось загрузить данные диалога.
|
||
</div>
|
||
|
||
<div v-if="conversationData && !loadingInitialMessages" class="chat-header p-3 bg-light border-bottom">
|
||
<h5 class="mb-0">Чат с {{ getOtherParticipantName() || 'Собеседник' }}</h5>
|
||
</div>
|
||
|
||
<div ref="messagesContainer" class="messages-area flex-grow-1 p-3" style="overflow-y: auto;">
|
||
<template v-for="item in messagesWithDateSeparators" :key="item.id">
|
||
<div v-if="item.type === 'date_separator'" class="date-separator text-center my-2">
|
||
<span class="badge bg-secondary-subtle text-dark-emphasis">{{ formatDateHeader(item.timestamp) }}</span>
|
||
</div>
|
||
<div v-else-if="item.type === 'message'"
|
||
:class="['message-item-wrapper', item.data.sender?._id === currentUser?._id ? 'sent-wrapper' : 'received-wrapper']"
|
||
@mouseenter="!isTouchDevice.value && handleMouseEnter(item.data)"
|
||
@mouseleave="!isTouchDevice.value && handleMouseLeave()"
|
||
|
||
>
|
||
<div :class="['message-bubble', item.data.sender?._id === currentUser?._id ? 'sent' : 'received']"
|
||
@click.stop="handleMessageTap(item.data, $event)"
|
||
@contextmenu.stop="handleNativeContextMenu($event, item.data)">
|
||
<div class="message-content">
|
||
<p class="mb-0">{{ item.data.text }}</p>
|
||
<small class="message-timestamp text-muted">
|
||
{{ formatMessageTimestamp(item.data.createdAt) }}
|
||
<span v-if="item.data.isEdited" 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>
|
||
</span>
|
||
</small>
|
||
</div>
|
||
</div>
|
||
<!-- Кнопка удаления для ПК (появляется при наведении) -->
|
||
<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>
|
||
|
||
<!-- Контекстное меню для мобильных устройств -->
|
||
<div v-if="contextMenu && contextMenu.visible"
|
||
ref="contextMenuRef"
|
||
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>
|
||
</li>
|
||
</ul>
|
||
</div>
|
||
|
||
<!-- Индикатор "печатает" -->
|
||
<div v-if="otherUserIsTyping" class="typing-indicator px-3 pb-2 text-muted fst-italic">
|
||
{{ getOtherParticipantName() || 'Собеседник' }} печатает...
|
||
</div>
|
||
|
||
<div v-if="isAuthenticated" class="message-input-area p-3 border-top bg-light">
|
||
<form @submit.prevent="editingMessageId ? saveEditedMessage() : sendMessage()" class="d-flex">
|
||
<input
|
||
type="text"
|
||
v-model="newMessageText"
|
||
class="form-control me-2"
|
||
:placeholder="editingMessageId ? 'Редактирование сообщения...' : 'Введите сообщение...'"
|
||
:disabled="sendingMessage || savingEdit"
|
||
ref="messageInputRef"
|
||
/>
|
||
<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>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup>
|
||
import { ref, onMounted, onUnmounted, nextTick, computed, watch } from 'vue';
|
||
import { useRoute } from 'vue-router';
|
||
import api from '@/services/api';
|
||
import { useAuth } from '@/auth';
|
||
import { getSocket, connectSocket, disconnectSocket } from '@/services/socketService';
|
||
|
||
const route = useRoute();
|
||
const { user: currentUser, isAuthenticated } = useAuth();
|
||
|
||
const isTouchDevice = ref(false);
|
||
const hoveredMessageId = ref(null);
|
||
const contextMenu = ref({ visible: false, x: 0, y: 0, messageId: null });
|
||
const contextMenuRef = ref(null);
|
||
|
||
const handleMouseEnter = (message) => {
|
||
if (!isTouchDevice.value) {
|
||
hoveredMessageId.value = message._id;
|
||
}
|
||
};
|
||
|
||
const handleMouseLeave = () => {
|
||
if (!isTouchDevice.value) {
|
||
hoveredMessageId.value = null;
|
||
}
|
||
};
|
||
|
||
// New handler for tap/click
|
||
const handleMessageTap = (message, event) => {
|
||
if (message.sender?._id === currentUser.value?._id) {
|
||
// event.preventDefault(); // Already handled by contextmenu or default click behavior if not a link
|
||
// event.stopPropagation(); // Handled by .stop modifier in template
|
||
showContextMenu(message, event);
|
||
}
|
||
// For messages from other users, allow default behavior (e.g., text selection, link clicks)
|
||
};
|
||
|
||
const showContextMenu = (message, eventSource) => {
|
||
if (message.sender?._id === currentUser.value?._id) {
|
||
contextMenu.value.messageId = message._id;
|
||
let x = eventSource.clientX;
|
||
let y = eventSource.clientY;
|
||
|
||
// Set initial position and make visible to calculate dimensions
|
||
contextMenu.value.x = x;
|
||
contextMenu.value.y = y;
|
||
contextMenu.value.visible = true;
|
||
|
||
nextTick(() => {
|
||
if (contextMenuRef.value) {
|
||
const menuWidth = contextMenuRef.value.offsetWidth;
|
||
const menuHeight = contextMenuRef.value.offsetHeight;
|
||
const screenWidth = window.innerWidth;
|
||
const screenHeight = window.innerHeight;
|
||
const PADDING = 10; // Screen edge padding
|
||
|
||
if (x + menuWidth > screenWidth - PADDING) {
|
||
x = screenWidth - menuWidth - PADDING;
|
||
}
|
||
if (x < PADDING) {
|
||
x = PADDING;
|
||
}
|
||
|
||
if (y + menuHeight > screenHeight - PADDING) {
|
||
y = screenHeight - menuHeight - PADDING;
|
||
}
|
||
if (y < PADDING) {
|
||
y = PADDING;
|
||
}
|
||
|
||
contextMenu.value.x = x;
|
||
contextMenu.value.y = y;
|
||
}
|
||
});
|
||
} else {
|
||
contextMenu.value.visible = false;
|
||
}
|
||
};
|
||
|
||
const conversationId = ref(route.params.conversationId);
|
||
const conversationData = ref(null); // Для информации о диалоге (например, участники)
|
||
const messages = ref([]);
|
||
const newMessageText = ref('');
|
||
|
||
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
|
||
const userHasSentTypingEvent = ref(false); // Отслеживает, отправляли ли 'typing' для текущей сессии набора
|
||
const TYPING_TIMER_LENGTH = 1500; // мс, через сколько отправлять stopTyping
|
||
|
||
const messagesWithDateSeparators = computed(() => {
|
||
if (!messages.value || messages.value.length === 0) return [];
|
||
|
||
const result = [];
|
||
let lastDate = null;
|
||
|
||
messages.value.forEach(message => {
|
||
const messageTimestamp = message.createdAt || Date.now(); // Fallback for temp messages if needed
|
||
const messageDate = new Date(messageTimestamp).toDateString();
|
||
if (messageDate !== lastDate) {
|
||
result.push({ type: 'date_separator', timestamp: messageTimestamp, id: `date-${messageTimestamp}-${message._id || Math.random()}` });
|
||
lastDate = messageDate;
|
||
}
|
||
result.push({ type: 'message', data: message, id: message._id || message.tempId || `msg-${Math.random()}` });
|
||
});
|
||
return result;
|
||
});
|
||
|
||
const formatDateHeader = (timestamp) => {
|
||
if (!timestamp) return '';
|
||
const date = new Date(timestamp);
|
||
const today = new Date();
|
||
const yesterday = new Date(today);
|
||
yesterday.setDate(yesterday.getDate() - 1);
|
||
|
||
if (date.toDateString() === today.toDateString()) {
|
||
return 'Сегодня';
|
||
} else if (date.toDateString() === yesterday.toDateString()) {
|
||
return 'Вчера';
|
||
} else {
|
||
return date.toLocaleDateString([], { day: 'numeric', month: 'long', year: 'numeric' });
|
||
}
|
||
};
|
||
|
||
// Функция для отметки сообщений как прочитанных
|
||
const markMessagesAsReadOnServer = () => {
|
||
if (socket && conversationId.value && currentUser.value?._id) {
|
||
// Проверим, есть ли вообще непрочитанные сообщения от собеседника
|
||
const unreadMessagesFromOther = messages.value.some(
|
||
msg => msg.sender?._id !== currentUser.value._id &&
|
||
(!msg.readBy || !msg.readBy.includes(currentUser.value._id))
|
||
);
|
||
|
||
if (unreadMessagesFromOther) {
|
||
console.log('[ChatView] Отправка события markMessagesAsRead для диалога:', conversationId.value);
|
||
socket.emit('markMessagesAsRead', {
|
||
conversationId: conversationId.value,
|
||
userId: currentUser.value._id,
|
||
});
|
||
}
|
||
}
|
||
};
|
||
|
||
// Функция для прокрутки к последнему сообщению
|
||
const messagesContainer = ref(null);
|
||
const scrollToBottom = () => {
|
||
nextTick(() => {
|
||
if (messagesContainer.value) {
|
||
messagesContainer.value.scrollTop = messagesContainer.value.scrollHeight;
|
||
}
|
||
});
|
||
};
|
||
|
||
const fetchInitialMessages = async () => {
|
||
if (!conversationId.value) return;
|
||
loadingInitialMessages.value = true;
|
||
error.value = '';
|
||
try {
|
||
console.log(`[ChatView] Запрос истории сообщений для диалога: ${conversationId.value}`);
|
||
const response = await api.getMessagesForConversation(conversationId.value);
|
||
// Теперь API возвращает объект { conversation: {}, messages: [] }
|
||
messages.value = response.data.messages || [];
|
||
conversationData.value = response.data.conversation; // <--- СОХРАНЯЕМ ДАННЫЕ ДИАЛОГА
|
||
|
||
console.log('[ChatView] Сообщения загружены:', messages.value);
|
||
console.log('[ChatView] Данные диалога загружены:', conversationData.value); // Для отладки
|
||
scrollToBottom();
|
||
markMessagesAsReadOnServer(); // Отмечаем как прочитанные при загрузке
|
||
} catch (err) {
|
||
console.error('[ChatView] Ошибка загрузки сообщений:', err);
|
||
error.value = err.response?.data?.message || 'Не удалось загрузить сообщения.';
|
||
} finally {
|
||
loadingInitialMessages.value = false;
|
||
}
|
||
};
|
||
|
||
const getOtherParticipant = () => {
|
||
if (conversationData.value && conversationData.value.participants && currentUser.value) {
|
||
return conversationData.value.participants.find(p => p._id !== currentUser.value._id);
|
||
}
|
||
return null;
|
||
};
|
||
const getOtherParticipantName = () => getOtherParticipant()?.name;
|
||
|
||
|
||
const sendMessage = () => {
|
||
if (!newMessageText.value.trim() || !socket || !currentUser.value || sendingMessage.value || editingMessageId.value) return;
|
||
|
||
const otherParticipant = getOtherParticipant(); // Нужно получить ID получателя!
|
||
// Это слабое место, если conversationData не загружено правильно.
|
||
// Для теста можно временно захардкодить receiverId, если знаешь его.
|
||
// Или передать его из ChatListView.
|
||
let receiverId;
|
||
// ВАЖНО: Логика получения receiverId должна быть надежной.
|
||
// Например, если мы перешли из списка чатов, мы можем передать инфо о собеседнике.
|
||
// Или при загрузке диалога, API должен вернуть участников.
|
||
// Для примера, если бы conversationData было установлено:
|
||
if (otherParticipant) {
|
||
receiverId = otherParticipant._id;
|
||
} else {
|
||
console.error("Не удалось определить получателя!");
|
||
error.value = "Не удалось определить получателя для отправки сообщения.";
|
||
return;
|
||
}
|
||
|
||
const messageData = {
|
||
senderId: currentUser.value._id,
|
||
receiverId: receiverId, // ID получателя
|
||
text: newMessageText.value,
|
||
conversationId: conversationId.value, // Можно отправлять, если нужно на сервере
|
||
// tempId: Date.now().toString() // Временный ID для оптимистичного обновления UI
|
||
};
|
||
|
||
// Оптимистичное добавление (можно улучшить, добавив статус isSending)
|
||
// const tempMessage = { ...messageData, createdAt: new Date().toISOString(), sender: currentUser.value, tempId: messageData.tempId, isSending: true };
|
||
// messages.value.push(tempMessage);
|
||
// scrollToBottom();
|
||
|
||
console.log('[ChatView] Отправка сообщения через сокет:', messageData);
|
||
sendingMessage.value = true;
|
||
socket.emit('sendMessage', messageData);
|
||
|
||
// sendingMessage будет false, когда придет подтверждение или ошибка от сервера (пока не реализовано)
|
||
// Пока просто сбросим через некоторое время или после получения своего же сообщения
|
||
// setTimeout(() => { sendingMessage.value = false; }, 1000);
|
||
newMessageText.value = ''; // Очищаем поле ввода
|
||
if (messageInputRef.value) messageInputRef.value.focus(); // Возвращаем фокус в поле ввода
|
||
};
|
||
|
||
const handleNewMessage = (message) => {
|
||
console.log('[ChatView] Получено новое сообщение:', message);
|
||
// Убедимся, что сообщение для текущего открытого диалога
|
||
if (message.conversationId === conversationId.value) {
|
||
messages.value.push(message);
|
||
scrollToBottom();
|
||
|
||
// Если это сообщение от другого пользователя, и мы в фокусе, отмечаем как прочитанное
|
||
if (message.sender?._id !== currentUser.value?._id && document.hasFocus()) {
|
||
markMessagesAsReadOnServer();
|
||
}
|
||
}
|
||
sendingMessage.value = false;
|
||
};
|
||
|
||
const handleMessageDeletedBySocket = (data) => {
|
||
console.log('[ChatView] Получено событие удаления сообщения от сокета:', data);
|
||
const { messageId, conversationId: convId } = data;
|
||
if (convId === conversationId.value) {
|
||
messages.value = messages.value.filter(msg => msg._id !== messageId);
|
||
// Дополнительно можно обновить UI, если это необходимо
|
||
// Например, если удаленное сообщение было последним, или для обновления счетчиков
|
||
}
|
||
};
|
||
|
||
// --- НОВЫЕ ФУНКЦИИ И ЛОГИКА ДЛЯ РЕДАКТИРОВАНИЯ ---
|
||
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 от сокета. Данные:', JSON.stringify(editedMessage), 'Для диалога:', convId); // Более детальный лог
|
||
if (convId === conversationId.value) {
|
||
const index = messages.value.findIndex(msg => msg._id === editedMessage._id);
|
||
if (index !== -1) {
|
||
console.log(`[ChatView] Найдено сообщение для обновления по ID ${editedMessage._id} по индексу ${index}. Старые данные:`, JSON.stringify(messages.value[index]));
|
||
// Обновляем существующее сообщение новыми данными
|
||
// Убедитесь, что editedMessage от сервера содержит поле isEdited: true и, возможно, editedAt
|
||
messages.value[index] = { ...messages.value[index], ...editedMessage };
|
||
console.log('[ChatView] Сообщение обновлено через сокет. Новые данные:', JSON.stringify(messages.value[index]));
|
||
} else {
|
||
console.warn('[ChatView] Получено отредактированное сообщение, которое не найдено локально:', editedMessage._id, 'Данные:', JSON.stringify(editedMessage));
|
||
// Если сообщение по какой-то причине отсутствует, но должно быть (например, из-за задержки синхронизации),
|
||
// можно рассмотреть его добавление, но это требует осторожности.
|
||
// messages.value.push(editedMessage);
|
||
// messages.value.sort((a, b) => new Date(a.createdAt) - new Date(b.createdAt)); // Поддерживать порядок
|
||
// scrollToBottom();
|
||
}
|
||
} else {
|
||
console.log(`[ChatView] Событие messageEdited для другого диалога (${convId}), текущий открытый: ${conversationId.value}`);
|
||
}
|
||
};
|
||
// --- КОНЕЦ ЛОГИКИ РЕДАКТИРОВАНИЯ ---
|
||
|
||
|
||
const confirmDeleteMessage = (messageId) => {
|
||
// Логика подтверждения удаления (например, показать модальное окно)
|
||
// А затем, если пользователь подтвердил, вызвать deleteMessage
|
||
console.log('Confirm delete messageId:', messageId);
|
||
// Пока просто вызываем deleteMessage напрямую для примера
|
||
deleteMessage(messageId);
|
||
};
|
||
|
||
const deleteMessage = async (messageId) => {
|
||
try {
|
||
// Оптимистичное удаление из UI
|
||
messages.value = messages.value.filter(msg => msg._id !== messageId);
|
||
// Отправка запроса на сервер
|
||
// ВАЖНО: Убедитесь, что conversationId.value передается в api.deleteMessage
|
||
await api.deleteMessage(conversationId.value, messageId);
|
||
console.log(`[ChatView] Сообщение ${messageId} удалено локально и на сервере.`);
|
||
// Сокет событие messageDeleted должно прийти от сервера и подтвердить удаление
|
||
// или обработать его для других клиентов. Если вы не хотите дублирования,
|
||
// можно не удалять из messages.value здесь, а дождаться события от сокета.
|
||
// Однако, оптимистичное удаление улучшает UX.
|
||
|
||
// Закрыть контекстное меню, если оно было открыто для этого сообщения
|
||
if (contextMenu.value && contextMenu.value.messageId === messageId) {
|
||
contextMenu.value.visible = false;
|
||
}
|
||
|
||
} catch (err) {
|
||
console.error(`[ChatView] Ошибка удаления сообщения ${messageId}:`, err);
|
||
error.value = err.response?.data?.message || 'Не удалось удалить сообщение.';
|
||
// Возможно, стоит вернуть сообщение в список, если удаление на сервере не удалось
|
||
// fetchInitialMessages(); // или более тонкое обновление
|
||
}
|
||
};
|
||
|
||
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) {
|
||
const updatedReadBy = msg.readBy ? [...msg.readBy] : [];
|
||
if (!updatedReadBy.includes(readerId)) {
|
||
updatedReadBy.push(readerId);
|
||
}
|
||
return { ...msg, readBy: updatedReadBy };
|
||
}
|
||
return msg;
|
||
});
|
||
}
|
||
};
|
||
|
||
const handleVisibilityChange = () => {
|
||
if (document.visibilityState === 'visible') {
|
||
markMessagesAsReadOnServer();
|
||
}
|
||
};
|
||
|
||
watch(newMessageText, (newValue /*, oldValue */) => { // oldValue больше не используется напрямую для этой логики
|
||
if (!socket || !isAuthenticated.value || !conversationData.value || !currentUser.value) return;
|
||
|
||
const otherParticipant = getOtherParticipant();
|
||
if (!otherParticipant?._id) {
|
||
console.warn("[ChatView] Не удалось определить собеседника для событий печати.");
|
||
return;
|
||
}
|
||
const receiverId = otherParticipant._id;
|
||
|
||
const payload = {
|
||
conversationId: conversationId.value,
|
||
receiverId: receiverId,
|
||
senderId: currentUser.value._id
|
||
};
|
||
|
||
clearTimeout(stopTypingEmitTimer);
|
||
|
||
if (newValue.trim().length > 0) {
|
||
if (!userHasSentTypingEvent.value) {
|
||
if (socket.connected) {
|
||
socket.emit('typing', payload);
|
||
userHasSentTypingEvent.value = true;
|
||
console.log('[ChatView] Emit typing (new burst)');
|
||
}
|
||
}
|
||
|
||
stopTypingEmitTimer = setTimeout(() => {
|
||
if (socket.connected) {
|
||
socket.emit('stopTyping', payload);
|
||
userHasSentTypingEvent.value = false;
|
||
console.log('[ChatView] Emit stopTyping (timeout)');
|
||
}
|
||
}, TYPING_TIMER_LENGTH);
|
||
|
||
} else {
|
||
if (userHasSentTypingEvent.value) {
|
||
if (socket.connected) {
|
||
socket.emit('stopTyping', payload);
|
||
userHasSentTypingEvent.value = false;
|
||
console.log('[ChatView] Emit stopTyping (cleared input)');
|
||
}
|
||
}
|
||
}
|
||
});
|
||
|
||
|
||
onMounted(async () => {
|
||
isTouchDevice.value = ('ontouchstart' in window) || (navigator.maxTouchPoints > 0);
|
||
if (!isAuthenticated.value) {
|
||
// router.push('/login'); // Оставил закомментированным, т.к. в оригинале так
|
||
return;
|
||
}
|
||
await fetchInitialMessages();
|
||
|
||
socket = getSocket();
|
||
if (!socket) {
|
||
socket = connectSocket();
|
||
}
|
||
|
||
if (socket) {
|
||
socket.on('getMessage', handleNewMessage);
|
||
socket.on('messagesRead', handleMessagesReadByOther);
|
||
socket.on('userTyping', ({ conversationId: incomingConvId, senderId }) => {
|
||
const otherP = getOtherParticipant();
|
||
if (incomingConvId === conversationId.value && otherP && senderId === otherP._id) {
|
||
otherUserIsTyping.value = true;
|
||
}
|
||
});
|
||
socket.on('userStopTyping', ({ conversationId: incomingConvId, senderId }) => {
|
||
const otherP = getOtherParticipant();
|
||
if (incomingConvId === conversationId.value && otherP && senderId === otherP._id) {
|
||
otherUserIsTyping.value = false;
|
||
}
|
||
});
|
||
socket.on('messageDeleted', handleMessageDeletedBySocket);
|
||
socket.on('messageEdited', handleMessageEditedBySocket); // <--- Подписываемся на событие редактирования
|
||
|
||
if (conversationId.value) {
|
||
socket.emit('joinConversationRoom', conversationId.value);
|
||
}
|
||
|
||
markMessagesAsReadOnServer();
|
||
document.addEventListener('visibilitychange', handleVisibilityChange);
|
||
window.addEventListener('focus', markMessagesAsReadOnServer);
|
||
document.addEventListener('click', handleClickOutsideContextMenu, true);
|
||
}
|
||
});
|
||
|
||
onUnmounted(() => {
|
||
if (socket) {
|
||
socket.off('getMessage', handleNewMessage);
|
||
socket.off('messagesRead', handleMessagesReadByOther);
|
||
socket.off('userTyping');
|
||
socket.off('userStopTyping');
|
||
socket.off('messageDeleted', handleMessageDeletedBySocket);
|
||
socket.off('messageEdited', handleMessageEditedBySocket); // <--- Отписываемся
|
||
|
||
if (conversationId.value) {
|
||
socket.emit('leaveConversationRoom', conversationId.value);
|
||
}
|
||
|
||
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
||
window.removeEventListener('focus', markMessagesAsReadOnServer);
|
||
document.removeEventListener('click', handleClickOutsideContextMenu, true); // Ensure this is removed
|
||
}
|
||
clearTimeout(stopTypingEmitTimer);
|
||
});
|
||
|
||
const formatMessageTimestamp = (timestamp) => {
|
||
if (!timestamp) return '';
|
||
return new Date(timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||
};
|
||
|
||
const getMessageStatusIcon = (message) => {
|
||
if (message.isSending) {
|
||
return 'bi bi-clock'; // Отправка (часы)
|
||
}
|
||
const otherParticipant = getOtherParticipant();
|
||
if (otherParticipant && message.readBy && message.readBy.includes(otherParticipant._id)) {
|
||
return 'bi bi-check2-all'; // Прочитано (две галочки)
|
||
}
|
||
return 'bi bi-check2'; // Доставлено (одна галочка)
|
||
};
|
||
|
||
const handleNativeContextMenu = (event, message) => {
|
||
if (message.sender?._id === currentUser.value?._id) {
|
||
event.preventDefault(); // Prevent native context menu for own messages to show custom one
|
||
}
|
||
// For other users' messages, allow native context menu (e.g., for copying text).
|
||
};
|
||
|
||
const handleClickOutsideContextMenu = (event) => {
|
||
if (contextMenu.value.visible && contextMenuRef.value && !contextMenuRef.value.contains(event.target)) {
|
||
contextMenu.value.visible = false;
|
||
}
|
||
};
|
||
|
||
</script>
|
||
|
||
<style scoped>
|
||
.chat-view-container {
|
||
height: calc(100vh - 56px - 3rem); /* Примерная высота: 100% высоты вьюпорта минус шапка и отступы */
|
||
/* Стилизуй это под свою шапку и футер */
|
||
}
|
||
.messages-area {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 0.75rem;
|
||
}
|
||
.message-bubble {
|
||
padding: 0.5rem 1rem;
|
||
border-radius: 1.25rem;
|
||
max-width: 70%;
|
||
word-wrap: break-word;
|
||
}
|
||
.message-bubble.sent {
|
||
background-color: #0d6efd; /* Bootstrap primary */
|
||
color: white;
|
||
margin-left: auto; /* Прижимаем к правому краю */
|
||
border-bottom-right-radius: 0.5rem;
|
||
}
|
||
.message-bubble.received {
|
||
background-color: #e9ecef; /* Bootstrap light grey */
|
||
color: #212529;
|
||
margin-right: auto; /* Прижимаем к левому краю */
|
||
border-bottom-left-radius: 0.5rem;
|
||
}
|
||
.message-content p {
|
||
margin-bottom: 0.25rem;
|
||
}
|
||
.message-timestamp {
|
||
font-size: 0.75em;
|
||
display: block;
|
||
text-align: right;
|
||
}
|
||
.message-bubble.received .message-timestamp {
|
||
text-align: left;
|
||
color: #6c757d !important; /* Немного темнее для серого фона */
|
||
}
|
||
.typing-indicator {
|
||
font-size: 0.85em;
|
||
min-height: 1.2em; /* Резервируем место, чтобы чат не прыгал */
|
||
}
|
||
.date-separator {
|
||
font-size: 0.85em;
|
||
}
|
||
.date-separator .badge {
|
||
padding: 0.4em 0.8em;
|
||
font-weight: 500;
|
||
}
|
||
|
||
/* Новые стили для удаления сообщений и контекстного меню */
|
||
.message-item-wrapper {
|
||
position: relative;
|
||
display: flex;
|
||
align-items: flex-start; /* Выравнивание по верху для bubble и кнопки */
|
||
/* Добавляем небольшой отступ снизу для ясности, если нужно */
|
||
/* margin-bottom: 4px; */
|
||
}
|
||
|
||
.message-item-wrapper.sent-wrapper {
|
||
justify-content: flex-end;
|
||
}
|
||
.message-item-wrapper.sent-wrapper .delete-message-btn {
|
||
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;
|
||
}
|
||
|
||
.delete-message-btn {
|
||
align-self: center; /* Вертикальное центрирование кнопки относительно bubble */
|
||
opacity: 0.6; /* Немного прозрачнее по умолчанию */
|
||
transition: opacity 0.2s ease-in-out;
|
||
padding: 0.2rem 0.4rem; /* Меньше padding */
|
||
font-size: 0.8rem; /* Меньше шрифт иконки */
|
||
line-height: 1; /* Убрать лишнюю высоту */
|
||
background: transparent;
|
||
border: none;
|
||
color: #dc3545; /* Цвет иконки (Bootstrap danger) */
|
||
}
|
||
.delete-message-btn:hover,
|
||
.delete-message-btn:focus {
|
||
opacity: 1;
|
||
background: rgba(220, 53, 69, 0.1); /* Легкий фон при наведении/фокусе */
|
||
}
|
||
.delete-message-btn i {
|
||
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;
|
||
background-color: white;
|
||
border: 1px solid #e0e0e0;
|
||
box-shadow: 0 2px 10px rgba(0,0,0,0.15);
|
||
border-radius: 6px;
|
||
z-index: 1050; /* Выше большинства элементов Bootstrap */
|
||
padding: 0;
|
||
min-width: 180px;
|
||
}
|
||
.context-menu ul {
|
||
list-style: none;
|
||
padding: 5px 0; /* Adjusted padding for ul */
|
||
margin: 0;
|
||
}
|
||
.context-menu li {
|
||
padding: 10px 15px;
|
||
cursor: pointer;
|
||
font-size: 0.95rem;
|
||
color: #333;
|
||
display: flex;
|
||
align-items: center;
|
||
}
|
||
.context-menu li:hover {
|
||
background-color: #f5f5f5;
|
||
color: #000;
|
||
}
|
||
|
||
.context-menu li i.bi-trash {
|
||
margin-right: 10px;
|
||
color: #dc3545; /* Bootstrap danger color */
|
||
font-size: 1.1em; /* Slightly larger icon */
|
||
}
|
||
|
||
.context-menu li:hover i.bi-trash {
|
||
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> |