Reflex/src/views/ChatView.vue

824 lines
34 KiB
Vue
Raw Normal View History

2025-05-21 22:13:09 +07:00
<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>
2025-05-21 22:13:09 +07:00
<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"
2025-05-21 22:13:09 +07:00
@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>
2025-05-21 22:13:09 +07:00
</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>
2025-05-21 22:13:09 +07:00
<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">
2025-05-21 22:13:09 +07:00
<input
type="text"
v-model="newMessageText"
class="form-control me-2"
:placeholder="editingMessageId ? 'Редактирование сообщения...' : 'Введите сообщение...'"
:disabled="sendingMessage || savingEdit"
ref="messageInputRef"
2025-05-21 22:13:09 +07:00
/>
<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"
>
Отмена
2025-05-21 22:13:09 +07:00
</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);
2025-05-21 22:13:09 +07:00
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;
2025-05-21 22:13:09 +07:00
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(); // Возвращаем фокус в поле ввода
2025-05-21 22:13:09 +07:00
};
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}`);
}
};
// --- КОНЕЦ ЛОГИКИ РЕДАКТИРОВАНИЯ ---
2025-05-21 22:13:09 +07:00
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) {
2025-05-21 22:13:09 +07:00
socket.emit('typing', payload);
userHasSentTypingEvent.value = true;
console.log('[ChatView] Emit typing (new burst)');
}
}
stopTypingEmitTimer = setTimeout(() => {
if (socket.connected) {
2025-05-21 22:13:09 +07:00
socket.emit('stopTyping', payload);
userHasSentTypingEvent.value = false;
2025-05-21 22:13:09 +07:00
console.log('[ChatView] Emit stopTyping (timeout)');
}
}, TYPING_TIMER_LENGTH);
} else {
if (userHasSentTypingEvent.value) {
if (socket.connected) {
2025-05-21 22:13:09 +07:00
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); // <--- Подписываемся на событие редактирования
2025-05-21 22:13:09 +07:00
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); // <--- Отписываемся
2025-05-21 22:13:09 +07:00
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; /* Кнопка редактирования левее кнопки удаления */
}
2025-05-21 22:13:09 +07:00
.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;
}
2025-05-21 22:13:09 +07:00
/* Стили для контекстного меню */
.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 */
}
2025-05-21 22:13:09 +07:00
/* ... other existing styles ... */
</style>