Reflex/src/views/ChatView.vue

1280 lines
36 KiB
Vue
Raw Normal View History

2025-05-21 22:13:09 +07:00
<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>
2025-05-21 22:13:09 +07:00
</div>
</div>
<!-- Loading State -->
<div v-if="loadingInitialMessages" class="loading-section">
<div class="loading-spinner">
<div class="spinner"></div>
<p>Загрузка сообщений...</p>
</div>
2025-05-21 22:13:09 +07:00
</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>
2025-05-21 22:13:09 +07:00
</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>
2025-05-21 22:13:09 +07:00
</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>
2025-05-21 22:13:09 +07:00
</div>
</div>
</div>
2025-05-21 22:13:09 +07:00
</div>
<!-- Context Menu (Mobile) -->
2025-05-21 22:13:09 +07:00
<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>
2025-05-21 22:13:09 +07:00
</button>
</div>
2025-05-21 22:13:09 +07:00
</div>
</div>
</template>
<script setup>
// Используем существующий script setup
2025-05-21 22:13:09 +07:00
import { ref, onMounted, onUnmounted, nextTick, computed, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
2025-05-21 22:13:09 +07:00
import api from '@/services/api';
import { useAuth } from '@/auth';
import { getSocket, connectSocket, disconnectSocket } from '@/services/socketService';
const route = useRoute();
const router = useRouter();
2025-05-21 22:13:09 +07:00
const { user: currentUser, isAuthenticated } = useAuth();
// Все существующие константы и функции остаются без изменений
2025-05-21 22:13:09 +07:00
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);
2025-05-21 22:13:09 +07:00
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);
2025-05-21 22:13:09 +07:00
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;
const userHasSentTypingEvent = ref(false);
const TYPING_TIMER_LENGTH = 1500;
2025-05-21 22:13:09 +07:00
// Другие вычисляемые свойства и методы
2025-05-21 22:13:09 +07:00
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();
2025-05-21 22:13:09 +07:00
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;
});
// Все оставшиеся методы остаются без изменений
2025-05-21 22:13:09 +07:00
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 для показа/скрытия кнопки прокрутки
2025-05-21 22:13:09 +07:00
const scrollToBottom = () => {
nextTick(() => {
if (messagesContainer.value) {
messagesContainer.value.scrollTop = messagesContainer.value.scrollHeight;
showScrollButton.value = false;
2025-05-21 22:13:09 +07:00
}
});
};
// Новый метод для проверки необходимости показа кнопки прокрутки
const checkScrollPosition = () => {
if (messagesContainer.value) {
const { scrollTop, scrollHeight, clientHeight } = messagesContainer.value;
// Показываем кнопку, если пользователь прокрутил вверх больше чем на 100px
showScrollButton.value = scrollHeight - scrollTop - clientHeight > 100;
}
};
2025-05-21 22:13:09 +07:00
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;
2025-05-21 22:13:09 +07:00
console.log('[ChatView] Сообщения загружены:', messages.value);
console.log('[ChatView] Данные диалога загружены:', conversationData.value);
2025-05-21 22:13:09 +07:00
scrollToBottom();
markMessagesAsReadOnServer();
2025-05-21 22:13:09 +07:00
} 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;
2025-05-21 22:13:09 +07:00
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();
2025-05-21 22:13:09 +07:00
let receiverId;
2025-05-21 22:13:09 +07:00
if (otherParticipant) {
receiverId = otherParticipant._id;
} else {
console.error("Не удалось определить получателя!");
error.value = "Не удалось определить получателя для отправки сообщения.";
return;
}
const messageData = {
senderId: currentUser.value._id,
receiverId: receiverId,
2025-05-21 22:13:09 +07:00
text: newMessageText.value,
conversationId: conversationId.value,
2025-05-21 22:13:09 +07:00
};
console.log('[ChatView] Отправка сообщения через сокет:', messageData);
sendingMessage.value = true;
socket.emit('sendMessage', messageData);
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);
if (contextMenu.value && contextMenu.value.messageId === messageId) {
contextMenu.value.visible = false;
}
2025-05-21 22:13:09 +07:00
}
};
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}`);
}
};
2025-05-21 22:13:09 +07:00
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) => {
2025-05-21 22:13:09 +07:00
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;
}
}
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
}
}, 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;
}
}
}
});
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);
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);
}
// Добавляем обработчик события прокрутки
if (messagesContainer.value) {
messagesContainer.value.addEventListener('scroll', checkScrollPosition);
}
2025-05-21 22:13:09 +07:00
});
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);
2025-05-21 22:13:09 +07:00
}
clearTimeout(stopTypingEmitTimer);
// Удаляем обработчик события прокрутки
if (messagesContainer.value) {
messagesContainer.value.removeEventListener('scroll', checkScrollPosition);
}
2025-05-21 22:13:09 +07:00
});
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';
2025-05-21 22:13:09 +07:00
}
const otherParticipant = getOtherParticipant();
if (otherParticipant && message.readBy && message.readBy.includes(otherParticipant._id)) {
return 'bi bi-check2-all';
2025-05-21 22:13:09 +07:00
}
return 'bi bi-check2';
2025-05-21 22:13:09 +07:00
};
const handleNativeContextMenu = (event, message) => {
if (message.sender?._id === currentUser.value?._id) {
event.preventDefault();
2025-05-21 22:13:09 +07:00
}
};
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%);
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(255, 255, 255, 0.1);
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);
2025-05-21 22:13:09 +07:00
}
.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 {
2025-05-21 22:13:09 +07:00
display: flex;
flex-direction: column;
}
.participant-name {
margin: 0;
font-size: 1.2rem;
font-weight: 600;
}
.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); }
2025-05-21 22:13:09 +07:00
}
/* Loading State */
.loading-section {
display: flex;
justify-content: center;
align-items: center;
min-height: 400px;
flex-grow: 1;
2025-05-21 22:13:09 +07:00
color: white;
}
.loading-spinner {
text-align: center;
2025-05-21 22:13:09 +07:00
}
.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;
2025-05-21 22:13:09 +07:00
}
.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;
2025-05-21 22:13:09 +07:00
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
2025-05-21 22:13:09 +07:00
}
/* Error and Empty States */
.error-section, .empty-section {
padding: 4rem 0;
flex-grow: 1;
2025-05-21 22:13:09 +07:00
}
.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;
2025-05-21 22:13:09 +07:00
}
/* 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;
2025-05-21 22:13:09 +07:00
}
/* Messages Container */
.messages-container {
display: flex;
flex-direction: column;
gap: 1rem;
padding: 1.5rem;
overflow-y: auto;
flex-grow: 1;
2025-05-21 22:13:09 +07:00
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 {
2025-05-21 22:13:09 +07:00
display: flex;
margin-bottom: 0.5rem;
2025-05-21 22:13:09 +07:00
}
.message-item.sent {
2025-05-21 22:13:09 +07:00
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;
2025-05-21 22:13:09 +07:00
}
.message-text {
margin: 0 0 0.5rem;
line-height: 1.4;
word-break: break-word;
}
2025-05-21 22:13:09 +07:00
.message-meta {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 0.5rem;
font-size: 0.7rem;
}
.received .message-meta {
2025-05-21 22:13:09 +07:00
justify-content: flex-start;
}
.message-time {
opacity: 0.8;
2025-05-21 22:13:09 +07:00
}
.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 {
2025-05-21 22:13:09 +07:00
opacity: 1;
transform: translateY(-50%) scale(1);
2025-05-21 22:13:09 +07:00
}
.sent .message-actions {
left: -60px; /* Outside message bubble */
2025-05-21 22:13:09 +07:00
}
.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 */
2025-05-21 22:13:09 +07:00
.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;
2025-05-21 22:13:09 +07:00
min-width: 180px;
}
.context-menu-content {
display: flex;
flex-direction: column;
2025-05-21 22:13:09 +07:00
}
.context-menu-item {
2025-05-21 22:13:09 +07:00
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;
2025-05-21 22:13:09 +07:00
}
.context-menu-item:hover {
background: #f8f9fa;
2025-05-21 22:13:09 +07:00
}
.context-menu-item i {
font-size: 1rem;
2025-05-21 22:13:09 +07:00
}
.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;
}
}
2025-05-21 22:13:09 +07:00
</style>