Reflex/src/views/ChatView.vue

824 lines
34 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<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>