694 lines
29 KiB
Vue
694 lines
29 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.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"
|
|||
|
@click="confirmDeleteMessage(item.data._id)"
|
|||
|
class="btn btn-sm btn-danger delete-message-btn"
|
|||
|
title="Удалить сообщение">
|
|||
|
<i class="bi bi-trash"></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="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="sendMessage" class="d-flex">
|
|||
|
<input
|
|||
|
type="text"
|
|||
|
v-model="newMessageText"
|
|||
|
class="form-control me-2"
|
|||
|
placeholder="Введите сообщение..."
|
|||
|
:disabled="sendingMessage"
|
|||
|
/>
|
|||
|
<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>
|
|||
|
</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('');
|
|||
|
|
|||
|
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) 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 = ''; // Очищаем поле ввода
|
|||
|
};
|
|||
|
|
|||
|
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 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) {
|
|||
|
// Если сообщение отправлено нами и еще не имеет этого readerId в readBy
|
|||
|
// (на случай если readBy не пришло сразу с сообщением или было обновлено)
|
|||
|
// Это упрощенный вариант. В идеале, сервер должен присылать обновленные сообщения.
|
|||
|
// Но для отображения галочек, достаточно знать, что ОДИН получатель (собеседник) прочитал.
|
|||
|
// Мы просто добавляем readerId в readBy для наших сообщений, если его там нет.
|
|||
|
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
|
|||
|
};
|
|||
|
|
|||
|
// Всегда очищаем таймер stopTyping при любом изменении текста
|
|||
|
clearTimeout(stopTypingEmitTimer);
|
|||
|
|
|||
|
if (newValue.trim().length > 0) {
|
|||
|
// Пользователь печатает (поле не пустое)
|
|||
|
if (!userHasSentTypingEvent.value) {
|
|||
|
// Если событие 'typing' еще не было отправлено для этой "серии" набора
|
|||
|
if (socket.connected) { // Убедимся, что сокет все еще подключен
|
|||
|
socket.emit('typing', payload);
|
|||
|
userHasSentTypingEvent.value = true;
|
|||
|
console.log('[ChatView] Emit typing (new burst)');
|
|||
|
}
|
|||
|
}
|
|||
|
|
|||
|
// Устанавливаем таймер для отправки 'stopTyping', если пользователь перестанет печатать
|
|||
|
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) {
|
|||
|
// Если событие 'typing' было активно, отправляем 'stopTyping'
|
|||
|
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);
|
|||
|
|
|||
|
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);
|
|||
|
|
|||
|
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'; // Отправка (часы)
|
|||
|
}
|
|||
|
// Для чата 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
|
|||
|
}
|
|||
|
// 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.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;
|
|||
|
}
|
|||
|
|
|||
|
/* Стили для контекстного меню */
|
|||
|
.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 */
|
|||
|
}
|
|||
|
|
|||
|
/* ... other existing styles ... */
|
|||
|
</style>
|