Reflex/src/views/ChatView.vue

1408 lines
38 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="modern-chat-view">
<!-- Header Section -->
<div v-if="conversationData && !loadingInitialMessages" class="chat-header">
<div class="container">
<div class="header-content">
<div class="chat-info">
<router-link to="/chats" class="back-button">
<i class="bi-arrow-left"></i>
</router-link>
<div class="participant-avatar">
<img
v-if="getOtherParticipant()?.mainPhotoUrl"
:src="getOtherParticipant()?.mainPhotoUrl"
:alt="getOtherParticipantName() || 'Фото собеседника'"
class="avatar-image"
/>
<div v-else class="avatar-placeholder">
<i class="bi-person"></i>
</div>
</div>
<div class="participant-info">
<h2 class="participant-name">{{ getOtherParticipantName() || 'Собеседник' }}</h2>
<p v-if="otherUserIsTyping" class="typing-status">
<span class="typing-animation">
<span class="dot"></span>
<span class="dot"></span>
<span class="dot"></span>
</span>
печатает...
</p>
</div>
</div>
</div>
</div>
</div>
<!-- Loading State -->
<div v-if="loadingInitialMessages" class="loading-section">
<div class="loading-spinner">
<div class="spinner"></div>
<p>Загрузка сообщений...</p>
</div>
</div>
<!-- Error State -->
<div v-if="error" class="error-section">
<div class="container">
<div class="error-card">
<i class="bi-exclamation-triangle"></i>
<h3>Ошибка загрузки</h3>
<p>{{ error }}</p>
<button class="retry-btn" @click="fetchInitialMessages">Попробовать снова</button>
</div>
</div>
</div>
<!-- No Data State -->
<div v-if="!loadingInitialMessages && !conversationData && !error" class="empty-section">
<div class="container">
<div class="empty-card">
<i class="bi-chat-dots"></i>
<h3>Диалог недоступен</h3>
<p>Не удалось загрузить данные диалога.</p>
<router-link to="/chats" class="action-btn primary">К списку диалогов</router-link>
</div>
</div>
</div>
<!-- Main Content -->
<div v-if="conversationData && !loadingInitialMessages && !error" class="main-content">
<div class="container">
<div class="chat-card">
<!-- Messages Container -->
<div ref="messagesContainer" class="messages-container">
<template v-for="item in messagesWithDateSeparators" :key="item.id">
<!-- Date Separator -->
<div v-if="item.type === 'date_separator'" class="date-separator">
<span class="date-badge">{{ formatDateHeader(item.timestamp) }}</span>
</div>
<!-- Message Item -->
<div v-else-if="item.type === 'message'"
:class="['message-item', item.data.sender?._id === currentUser?._id ? 'sent' : 'received']"
@mouseenter="!isTouchDevice.value && handleMouseEnter(item.data)"
@mouseleave="!isTouchDevice.value && handleMouseLeave()"
@click.stop="handleMessageTap(item.data, $event)"
@contextmenu.stop="handleNativeContextMenu($event, item.data)">
<div class="message-wrapper">
<div
class="message-bubble"
:class="{
'edited': item.data.isEdited,
'with-actions': !isTouchDevice.value && hoveredMessageId === item.data._id && item.data.sender?._id === currentUser?._id
}"
>
<div class="message-content">
<p class="message-text">{{ item.data.text }}</p>
<div class="message-meta">
<span class="message-time">{{ formatMessageTimestamp(item.data.createdAt) }}</span>
<span v-if="item.data.isEdited" class="message-edited">(изменено)</span>
<span v-if="item.data.sender?._id === currentUser?._id" class="message-status">
<i :class="getMessageStatusIcon(item.data)"></i>
</span>
</div>
</div>
</div>
<!-- Message Actions (visible on hover/desktop) -->
<div v-if="!isTouchDevice.value && hoveredMessageId === item.data._id && item.data.sender?._id === currentUser?._id && !editingMessageId"
class="message-actions">
<button @click="startEditMessage(item.data._id)" class="action-icon edit-icon" title="Редактировать">
<i class="bi-pencil-fill"></i>
</button>
<button @click="confirmDeleteMessage(item.data._id)" class="action-icon delete-icon" title="Удалить">
<i class="bi-trash"></i>
</button>
</div>
</div>
</div>
</template>
<!-- Scroll to Bottom Button -->
<button v-if="showScrollButton" class="scroll-bottom-btn" @click="scrollToBottom">
<i class="bi-arrow-down"></i>
</button>
</div>
<!-- Message Input Area -->
<div v-if="isAuthenticated" class="message-input-container">
<form @submit.prevent="editingMessageId ? saveEditedMessage() : sendMessage()" class="message-form">
<input
type="text"
v-model="newMessageText"
class="message-input"
:placeholder="editingMessageId ? 'Редактирование сообщения...' : 'Введите сообщение...'"
:disabled="sendingMessage || savingEdit"
ref="messageInputRef"
/>
<div class="input-actions">
<button
v-if="editingMessageId"
type="button"
class="action-btn secondary"
@click="cancelEditMessage"
:disabled="savingEdit"
>
Отмена
</button>
<button
type="submit"
class="action-btn primary"
:disabled="sendingMessage || savingEdit || !newMessageText.trim()"
>
<span v-if="sendingMessage || savingEdit" class="spinner-small"></span>
<i v-else :class="editingMessageId ? 'bi-check' : 'bi-send'"></i>
{{ editingMessageId ? 'Сохранить' : 'Отправить' }}
</button>
</div>
</form>
</div>
</div>
</div>
</div>
<!-- Context Menu (Mobile) -->
<div v-if="contextMenu && contextMenu.visible"
ref="contextMenuRef"
class="context-menu"
:style="{ top: contextMenu.y + 'px', left: contextMenu.x + 'px' }">
<div class="context-menu-content">
<button @click="startEditMessage(contextMenu.messageId)" class="context-menu-item">
<i class="bi-pencil-fill"></i>
<span>Редактировать</span>
</button>
<button @click="confirmDeleteMessage(contextMenu.messageId)" class="context-menu-item">
<i class="bi-trash"></i>
<span>Удалить</span>
</button>
</div>
</div>
</div>
</template>
<script setup>
// Используем существующий script setup
import { ref, onMounted, onUnmounted, nextTick, computed, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import api from '@/services/api';
import { useAuth } from '@/auth';
import { getSocket, connectSocket, disconnectSocket } from '@/services/socketService';
const route = useRoute();
const router = useRouter();
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 showScrollButton = ref(false);
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) {
showContextMenu(message, event);
}
};
const showContextMenu = (message, eventSource) => {
if (message.sender?._id === currentUser.value?._id) {
contextMenu.value.messageId = message._id;
let x = eventSource.clientX;
let y = eventSource.clientY;
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;
const userHasSentTypingEvent = ref(false);
const TYPING_TIMER_LENGTH = 1500;
// Другие вычисляемые свойства и методы
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();
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);
// Обновляем scrollToBottom для показа/скрытия кнопки прокрутки
const scrollToBottom = () => {
nextTick(() => {
if (messagesContainer.value) {
messagesContainer.value.scrollTop = messagesContainer.value.scrollHeight;
showScrollButton.value = false;
}
});
};
// Новый метод для проверки необходимости показа кнопки прокрутки
const checkScrollPosition = () => {
if (messagesContainer.value) {
const { scrollTop, scrollHeight, clientHeight } = messagesContainer.value;
// Показываем кнопку, если пользователь прокрутил вверх больше чем на 100px
showScrollButton.value = scrollHeight - scrollTop - clientHeight > 100;
}
};
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);
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();
let receiverId;
if (otherParticipant) {
receiverId = otherParticipant._id;
} else {
console.error("Не удалось определить получателя!");
error.value = "Не удалось определить получателя для отправки сообщения.";
return;
}
const messageData = {
senderId: currentUser.value._id,
receiverId: receiverId,
text: newMessageText.value,
conversationId: conversationId.value,
};
console.log('[ChatView] Отправка сообщения через сокет:', messageData);
sendingMessage.value = true;
socket.emit('sendMessage', messageData);
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);
if (contextMenu.value && contextMenu.value.messageId === messageId) {
contextMenu.value.visible = false;
}
}
};
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}"`);
const updatedMessage = await api.editMessage(conversationId.value, editingMessageId.value, newText);
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();
} catch (err) {
console.error('[ChatView] Ошибка при сохранении отредактированного сообщения:', err);
error.value = err.response?.data?.message || 'Не удалось сохранить изменения.';
} finally {
savingEdit.value = false;
}
};
const handleMessageEditedBySocket = ({ message: editedMessage, conversationId: convId }) => {
if (convId === conversationId.value) {
const index = messages.value.findIndex(msg => msg._id === editedMessage._id);
if (index !== -1) {
messages.value[index] = { ...messages.value[index], ...editedMessage };
console.log('[ChatView] Сообщение обновлено через сокет:', messages.value[index]);
} else {
console.warn('[ChatView] Получено отредактированное сообщение, которое не найдено локально:', editedMessage._id);
}
} else {
console.log(`[ChatView] Событие messageEdited для другого диалога (${convId}), текущий открытый: ${conversationId.value}`);
}
};
const confirmDeleteMessage = (messageId) => {
deleteMessage(messageId);
};
const deleteMessage = async (messageId) => {
try {
messages.value = messages.value.filter(msg => msg._id !== messageId);
await api.deleteMessage(conversationId.value, messageId);
console.log(`[ChatView] Сообщение ${messageId} удалено локально и на сервере.`);
if (contextMenu.value && contextMenu.value.messageId === messageId) {
contextMenu.value.visible = false;
}
} catch (err) {
console.error(`[ChatView] Ошибка удаления сообщения ${messageId}:`, err);
error.value = err.response?.data?.message || 'Не удалось удалить сообщение.';
}
};
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) => {
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;
}
}
stopTypingEmitTimer = setTimeout(() => {
if (socket.connected) {
socket.emit('stopTyping', payload);
userHasSentTypingEvent.value = false;
}
}, TYPING_TIMER_LENGTH);
} else {
if (userHasSentTypingEvent.value) {
if (socket.connected) {
socket.emit('stopTyping', payload);
userHasSentTypingEvent.value = false;
}
}
}
});
onMounted(async () => {
isTouchDevice.value = ('ontouchstart' in window) || (navigator.maxTouchPoints > 0);
if (!isAuthenticated.value) {
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);
}
// Добавляем обработчик события прокрутки
if (messagesContainer.value) {
messagesContainer.value.addEventListener('scroll', checkScrollPosition);
}
});
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);
}
clearTimeout(stopTypingEmitTimer);
// Удаляем обработчик события прокрутки
if (messagesContainer.value) {
messagesContainer.value.removeEventListener('scroll', checkScrollPosition);
}
});
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();
}
};
const handleClickOutsideContextMenu = (event) => {
if (contextMenu.value.visible && contextMenuRef.value && !contextMenuRef.value.contains(event.target)) {
contextMenu.value.visible = false;
}
};
</script>
<style scoped>
/* Global Reset and Base Styles */
.modern-chat-view {
min-height: 100vh;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
background-attachment: fixed; /* Фиксирует фон для растяжения на весь экран */
background-size: cover;
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
margin: 0;
padding: 0;
width: 100%;
display: flex;
flex-direction: column;
position: relative;
}
.container {
max-width: 800px;
margin: 0 auto;
width: 100%;
padding: 0 15px;
}
/* Header Section */
.chat-header {
background: rgba(33, 33, 60, 0.8); /* Более темный, менее прозрачный фон */
backdrop-filter: blur(10px);
border-bottom: 1px solid rgba(255, 255, 255, 0.2);
padding: 1rem 0;
color: white;
position: sticky;
top: 0;
z-index: 10;
}
.header-content {
display: flex;
align-items: center;
justify-content: space-between;
}
.chat-info {
display: flex;
align-items: center;
gap: 1rem;
}
.back-button {
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.2);
color: white;
text-decoration: none;
transition: all 0.3s ease;
}
.back-button:hover {
background: rgba(255, 255, 255, 0.3);
transform: translateX(-2px);
}
/* Аватар участника (новый) */
.participant-avatar {
width: 40px;
height: 40px;
border-radius: 50%;
overflow: hidden;
border: 2px solid white;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
}
.avatar-image {
width: 100%;
height: 100%;
object-fit: cover;
}
.avatar-placeholder {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background: rgba(255, 255, 255, 0.3);
}
.avatar-placeholder i {
font-size: 1.5rem;
color: white;
}
.participant-info {
display: flex;
flex-direction: column;
}
.participant-name {
margin: 0;
font-size: 1.2rem;
font-weight: 600;
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.5); /* Добавлена тень для лучшей видимости текста */
color: #ffffff; /* Гарантирует белый цвет текста */
}
.typing-status {
margin: 0;
font-size: 0.8rem;
opacity: 0.8;
font-style: italic;
display: flex;
align-items: center;
gap: 0.4rem;
}
/* Анимация "печатает" */
.typing-animation {
display: flex;
gap: 3px;
align-items: center;
}
.dot {
width: 4px;
height: 4px;
background-color: white;
border-radius: 50%;
display: inline-block;
animation: bouncingDots 1.4s infinite;
}
.dot:nth-child(2) {
animation-delay: 0.2s;
}
.dot:nth-child(3) {
animation-delay: 0.4s;
}
@keyframes bouncingDots {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-4px); }
}
/* Loading State */
.loading-section {
display: flex;
justify-content: center;
align-items: center;
min-height: 400px;
flex-grow: 1;
color: white;
}
.loading-spinner {
text-align: center;
}
.spinner {
width: 50px;
height: 50px;
border: 4px solid rgba(255, 255, 255, 0.3);
border-left: 4px solid white;
border-radius: 50%;
animation: spin 1s linear infinite;
margin: 0 auto 1rem;
}
.spinner-small {
display: inline-block;
width: 16px;
height: 16px;
border: 2px solid rgba(255, 255, 255, 0.3);
border-left: 2px solid white;
border-radius: 50%;
animation: spin 0.8s linear infinite;
margin-right: 0.5rem;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
/* Error and Empty States */
.error-section, .empty-section {
padding: 4rem 0;
flex-grow: 1;
}
.error-card, .empty-card {
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(10px);
border-radius: 20px;
padding: 3rem;
text-align: center;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2);
}
.error-card i, .empty-card i {
font-size: 3rem;
margin-bottom: 1rem;
}
.error-card i {
color: #dc3545;
}
.empty-card i {
color: #6c757d;
}
.error-card h3, .empty-card h3 {
color: #495057;
margin-bottom: 1rem;
}
.retry-btn {
background: linear-gradient(45deg, #667eea, #764ba2);
color: white;
border: none;
padding: 0.75rem 2rem;
border-radius: 50px;
font-weight: 600;
cursor: pointer;
transition: all 0.3s ease;
}
.retry-btn:hover {
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(102, 126, 234, 0.4);
}
/* Main Content */
.main-content {
display: flex;
flex-direction: column;
flex-grow: 1;
padding: 1rem 0;
position: relative;
}
/* Chat Card (новое) */
.chat-card {
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(10px);
border-radius: 20px;
overflow: hidden;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.1);
display: flex;
flex-direction: column;
height: calc(100vh - 140px);
position: relative;
margin: 0 auto; /* Центрировать карточку */
width: 100%; /* Ширина 100% в контейнере */
}
/* Messages Container */
.messages-container {
display: flex;
flex-direction: column;
gap: 1rem;
padding: 1.5rem;
overflow-y: auto;
flex-grow: 1;
position: relative;
height: calc(100% - 80px);
scrollbar-width: thin;
scrollbar-color: rgba(102, 126, 234, 0.5) rgba(255, 255, 255, 0.1);
}
.messages-container::-webkit-scrollbar {
width: 6px;
}
.messages-container::-webkit-scrollbar-track {
background: rgba(255, 255, 255, 0.1);
}
.messages-container::-webkit-scrollbar-thumb {
background-color: rgba(102, 126, 234, 0.5);
border-radius: 6px;
}
/* Кнопка прокрутки вниз (новая) */
.scroll-bottom-btn {
position: absolute;
right: 20px;
bottom: 20px;
width: 40px;
height: 40px;
border-radius: 50%;
background: linear-gradient(45deg, #667eea, #764ba2);
color: white;
border: none;
box-shadow: 0 4px 15px rgba(102, 126, 234, 0.4);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.3s ease;
font-size: 1.2rem;
z-index: 5;
}
.scroll-bottom-btn:hover {
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(102, 126, 234, 0.6);
}
/* Date Separator */
.date-separator {
display: flex;
align-items: center;
justify-content: center;
margin: 1.5rem 0;
}
.date-badge {
background: rgba(255, 255, 255, 0.9);
color: #495057;
padding: 0.5rem 1rem;
border-radius: 20px;
font-size: 0.9rem;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
}
/* Message Styles */
.message-item {
display: flex;
margin-bottom: 0.5rem;
}
.message-item.sent {
justify-content: flex-end;
}
.message-wrapper {
position: relative;
max-width: 75%;
}
.message-bubble {
padding: 0.8rem 1rem;
border-radius: 18px;
position: relative;
transition: all 0.2s ease;
}
.sent .message-bubble {
background: linear-gradient(135deg, #667eea, #764ba2);
color: white;
border-bottom-right-radius: 4px;
box-shadow: 0 2px 10px rgba(102, 126, 234, 0.2);
}
.received .message-bubble {
background: rgba(255, 255, 255, 0.95);
color: #333;
border-bottom-left-radius: 4px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
}
.sent .message-bubble.edited {
background: linear-gradient(135deg, #5c70d6, #6742a6);
}
.sent .message-bubble.with-actions {
box-shadow: 0 4px 15px rgba(102, 126, 234, 0.3);
}
.message-content {
position: relative;
}
.message-text {
margin: 0 0 0.5rem;
line-height: 1.4;
word-break: break-word;
}
.message-meta {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 0.5rem;
font-size: 0.7rem;
}
.received .message-meta {
justify-content: flex-start;
}
.message-time {
opacity: 0.8;
}
.message-edited {
font-style: italic;
opacity: 0.8;
}
.message-status {
opacity: 0.8;
}
.message-actions {
position: absolute;
top: 50%;
transform: translateY(-50%);
display: flex;
gap: 0.5rem;
opacity: 0;
transition: opacity 0.3s ease, transform 0.3s ease;
}
.sent .message-wrapper:hover .message-actions {
opacity: 1;
transform: translateY(-50%) scale(1);
}
.sent .message-actions {
left: -60px; /* Outside message bubble */
}
.action-icon {
width: 28px;
height: 28px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
border: none;
cursor: pointer;
transition: all 0.2s ease;
background: rgba(255, 255, 255, 0.95);
color: #495057;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
}
.edit-icon:hover {
background: #0dcaf0;
color: white;
transform: scale(1.1);
}
.delete-icon:hover {
background: #dc3545;
color: white;
transform: scale(1.1);
}
/* Message Input Container */
.message-input-container {
padding: 1rem;
background: white;
border-top: 1px solid rgba(0, 0, 0, 0.05);
}
.message-form {
display: flex;
align-items: center;
gap: 0.75rem;
position: relative;
}
.message-input {
flex-grow: 1;
padding: 0.8rem 1rem;
border: 1px solid rgba(0, 0, 0, 0.1);
border-radius: 24px;
font-size: 0.95rem;
background: white;
transition: all 0.3s ease;
}
.message-input:focus {
outline: none;
border-color: #667eea;
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.2);
}
.input-actions {
display: flex;
gap: 0.5rem;
}
/* Action Buttons */
.action-btn {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.8rem 1.25rem;
border: none;
border-radius: 50px;
font-weight: 600;
font-size: 0.9rem;
transition: all 0.3s ease;
cursor: pointer;
}
.action-btn.primary {
background: linear-gradient(45deg, #667eea, #764ba2);
color: white;
box-shadow: 0 4px 15px rgba(102, 126, 234, 0.3);
}
.action-btn.secondary {
background: #f1f3f5;
color: #495057;
}
.action-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.action-btn.primary:not(:disabled):hover {
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(102, 126, 234, 0.4);
}
.action-btn.secondary:not(:disabled):hover {
background: #e9ecef;
}
/* Context Menu */
.context-menu {
position: fixed;
z-index: 1000;
background: white;
border-radius: 12px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
overflow: hidden;
min-width: 180px;
}
.context-menu-content {
display: flex;
flex-direction: column;
}
.context-menu-item {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.8rem 1rem;
border: none;
background: none;
text-align: left;
cursor: pointer;
transition: background 0.2s;
}
.context-menu-item:hover {
background: #f8f9fa;
}
.context-menu-item i {
font-size: 1rem;
}
.context-menu-item:first-child i {
color: #0dcaf0;
}
.context-menu-item:last-child i {
color: #dc3545;
}
/* Responsive adjustments */
@media (max-width: 576px) {
.chat-card {
height: calc(100vh - 120px);
margin: 0;
border-radius: 0;
}
.message-wrapper {
max-width: 85%;
}
.message-actions {
display: none; /* Hide on mobile, use context menu instead */
}
.messages-container {
padding: 1rem;
}
.chat-input {
padding: 0.8rem;
}
.message-bubble {
padding: 0.7rem 0.9rem;
}
.message-meta {
font-size: 0.65rem;
}
.scroll-bottom-btn {
width: 36px;
height: 36px;
right: 15px;
bottom: 15px;
}
.participant-name {
font-size: 1.1rem;
}
.back-button {
width: 32px;
height: 32px;
}
}
/* Small phones */
@media (max-width: 375px) {
.message-wrapper {
max-width: 90%;
}
.chat-input {
padding: 0.6rem;
}
.message-text {
font-size: 0.95rem;
}
.message-typing {
font-size: 0.85rem;
}
.context-menu {
width: 160px;
}
.chat-info {
gap: 0.5rem;
}
.action-btn {
padding: 0.6rem 1rem;
font-size: 0.85rem;
}
}
/* Large phones and small tablets */
@media (min-width: 577px) and (max-width: 767px) {
.chat-card {
height: calc(100vh - 130px);
}
.message-wrapper {
max-width: 80%;
}
.message-actions {
opacity: 0.7; /* Always visible but slightly transparent */
}
}
/* Tablets */
@media (min-width: 768px) and (max-width: 991px) {
.container {
max-width: 700px;
}
.chat-card {
height: calc(100vh - 140px);
}
}
/* Small desktops */
@media (min-width: 992px) and (max-width: 1199px) {
.container {
max-width: 800px;
}
}
/* Landscape orientation on mobile */
@media (max-height: 500px) and (orientation: landscape) {
.chat-header {
padding: 0.5rem 0;
}
.chat-card {
height: calc(100vh - 80px);
}
.messages-container {
padding: 0.8rem;
height: calc(100% - 60px);
}
.chat-input-container {
padding: 0.5rem;
min-height: 60px;
}
.message-item {
margin-bottom: 0.4rem;
}
.date-separator {
margin: 0.8rem 0;
}
}
</style>