Reflex/src/views/ChatView.vue
Professional 42be354b9b фикс
2025-05-24 22:52:31 +07:00

1320 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="app-chat-view">
<!-- Фиксированный хедер -->
<div v-if="conversationData && !loadingInitialMessages" class="chat-header">
<div class="header-content">
<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>
<!-- Основное содержимое -->
<main class="chat-content">
<!-- 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="error-card">
<i class="bi-exclamation-triangle"></i>
<h3>Ошибка загрузки</h3>
<p>{{ error }}</p>
<button class="retry-btn" @click="fetchInitialMessages">Попробовать снова</button>
</div>
</div>
<!-- No Data State -->
<div v-if="!loadingInitialMessages && !conversationData && !error" class="empty-section">
<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>
<!-- Messages Container -->
<div v-if="conversationData && !loadingInitialMessages && !error" 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-inside">
<i :class="getMessageStatusIcon(item.data)"></i>
</span>
</div>
</div>
</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>
</main>
<!-- Фиксированный нижний блок для ввода сообщений -->
<div v-if="isAuthenticated && conversationData" 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>
<!-- 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 } 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,
};
// Создаем временное сообщение с локальным ID и статусом "отправляется"
const tempMessage = {
_id: `temp-${Date.now()}`,
tempId: `temp-${Date.now()}`, // используем tempId для идентификации временного сообщения
conversationId: conversationId.value,
sender: currentUser.value, // Используем полный объект текущего пользователя
text: newMessageText.value,
createdAt: new Date(),
status: 'sending' // Устанавливаем начальный статус "отправляется"
};
// Добавляем временное сообщение в массив
messages.value.push(tempMessage);
scrollToBottom();
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) {
// Проверяем, является ли это сообщение подтверждением отправки ранее добавленного временного сообщения
if (message.sender?._id === currentUser.value?._id) {
// Находим и удаляем все временные сообщения (они могут накапливаться при быстрой отправке)
messages.value = messages.value.filter(msg => !msg.tempId);
}
// Проверяем, есть ли поле status в сообщении, если нет - добавляем
if (!message.status) {
message.status = 'delivered';
}
// Добавляем полученное сообщение
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, status }) => {
if (readConversationId === conversationId.value) {
console.log(`[ChatView] Сообщения в диалоге ${readConversationId} прочитаны пользователем ${readerId}`);
messages.value = messages.value.map(msg => {
if (msg.sender?._id === currentUser.value?._id) {
// Обновляем массив readBy
const updatedReadBy = msg.readBy ? [...msg.readBy] : [];
if (!updatedReadBy.includes(readerId)) {
updatedReadBy.push(readerId);
}
// Обновляем статус сообщения на 'read'
return {
...msg,
readBy: updatedReadBy,
status: 'read'
};
}
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) => {
// Приоритет отдаем полю status, если оно есть
if (message.status) {
switch (message.status) {
case 'sending':
return 'bi bi-clock';
case 'delivered':
return 'bi bi-check2';
case 'read':
return 'bi bi-check2-all';
default:
return 'bi bi-check2';
}
}
// Для обратной совместимости проверяем старую логику, если поле status не установлено
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>
/* Основные стили */
.app-chat-view {
display: flex;
flex-direction: column;
height: 100vh;
width: 100%;
position: relative;
background-color: #f8f9fa;
background-image: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
overflow: hidden;
}
/* Хедер */
.chat-header {
background: rgba(33, 33, 60, 0.9);
backdrop-filter: blur(10px);
border-bottom: 1px solid rgba(255, 255, 255, 0.2);
padding: 0.8rem 1rem;
color: white;
position: fixed; /* Меняем со sticky на fixed */
top: 0;
left: 0;
right: 0;
z-index: 100; /* Увеличиваем z-index для правильного отображения */
flex-shrink: 0;
height: var(--header-height, 56px);
}
.header-content {
display: flex;
align-items: center;
gap: 1rem;
max-width: 800px;
margin: 0 auto;
width: 100%;
}
.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.2s ease;
flex-shrink: 0;
}
.participant-avatar {
width: 40px;
height: 40px;
border-radius: 50%;
overflow: hidden;
border: 2px solid white;
flex-shrink: 0;
}
.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 {
overflow: hidden;
}
.participant-name {
margin: 0;
font-size: 1.1rem;
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.typing-status {
margin: 0;
font-size: 0.8rem;
opacity: 0.9;
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); }
}
/* Основная область содержимого */
.chat-content {
flex: 1;
overflow: hidden;
position: relative;
display: flex;
flex-direction: column;
padding-top: var(--header-height, 56px); /* Добавляем отступ сверху для фиксированного хедера */
padding-bottom: calc(70px + var(--nav-height, 60px)); /* Отступ снизу для блока ввода и навигационной панели */
}
/* Состояния загрузки и ошибки */
.loading-section,
.error-section,
.empty-section {
display: flex;
justify-content: center;
align-items: center;
flex: 1;
padding: 1rem;
}
.loading-spinner {
text-align: center;
color: white;
}
.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-card,
.empty-card {
background: white;
border-radius: 16px;
padding: 2rem;
text-align: center;
box-shadow: 0 10px 20px rgba(0, 0, 0, 0.1);
max-width: 400px;
width: 100%;
}
.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: #343a40;
margin-bottom: 1rem;
}
.retry-btn,
.action-btn.primary {
background: linear-gradient(45deg, #667eea, #764ba2);
color: white;
border: none;
padding: 0.75rem 1.5rem;
border-radius: 50px;
font-weight: 600;
cursor: pointer;
transition: all 0.3s ease;
text-decoration: none;
display: inline-block;
box-shadow: 0 4px 10px rgba(102, 126, 234, 0.3);
}
/* Контейнер сообщений */
.messages-container {
flex: 1;
overflow-y: auto;
padding: 1rem;
scrollbar-width: thin;
scrollbar-color: rgba(102, 126, 234, 0.5) rgba(255, 255, 255, 0.1);
display: flex;
flex-direction: column;
gap: 0.8rem;
-webkit-overflow-scrolling: touch;
}
.messages-container::-webkit-scrollbar {
width: 5px;
}
.messages-container::-webkit-scrollbar-track {
background: transparent;
}
.messages-container::-webkit-scrollbar-thumb {
background-color: rgba(102, 126, 234, 0.5);
border-radius: 6px;
}
/* Кнопка прокрутки вниз */
.scroll-bottom-btn {
position: absolute;
right: 16px;
bottom: 16px;
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;
z-index: 5;
}
/* Разделитель даты */
.date-separator {
display: flex;
align-items: center;
justify-content: center;
margin: 1rem 0;
}
.date-badge {
background: rgba(255, 255, 255, 0.9);
color: #495057;
padding: 0.5rem 1rem;
border-radius: 20px;
font-size: 0.85rem;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
/* Стили сообщений */
.message-item {
display: flex;
margin-bottom: 0.4rem;
}
.message-item.sent {
justify-content: flex-end;
}
.message-wrapper {
position: relative;
max-width: 80%;
}
.message-bubble {
padding: 0.7rem 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 8px rgba(102, 126, 234, 0.2);
}
.received .message-bubble {
background: white;
color: #333;
border-bottom-left-radius: 4px;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.08);
}
.message-text {
margin: 0 0 0.4rem;
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-actions {
position: absolute;
top: 50%;
transform: translateY(-50%);
left: -60px;
display: flex;
gap: 0.4rem;
opacity: 0;
transition: opacity 0.3s ease;
}
.sent .message-wrapper:hover .message-actions {
opacity: 1;
}
.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: white;
color: #495057;
}
.edit-icon:hover {
background: #0dcaf0;
color: white;
}
.delete-icon:hover {
background: #dc3545;
color: white;
}
/* Контейнер ввода сообщений */
.message-input-container {
background: white;
border-top: 1px solid rgba(0, 0, 0, 0.1);
padding: 0.8rem 1rem;
position: fixed;
bottom: var(--nav-height, 60px); /* Размещаем прямо над навигационными табами */
left: 0;
right: 0;
z-index: 100;
max-width: 800px;
margin: 0 auto;
width: 100%;
box-shadow: 0 -2px 10px rgba(0, 0, 0, 0.05);
}
.message-form {
display: flex;
align-items: center;
gap: 0.75rem;
}
.message-input {
flex: 1;
padding: 0.7rem 1rem;
border: 1px solid rgba(0, 0, 0, 0.1);
border-radius: 24px;
font-size: 0.95rem;
background: white;
}
.message-input:focus {
outline: none;
border-color: #667eea;
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.15);
}
.input-actions {
display: flex;
gap: 0.5rem;
}
.action-btn {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.7rem 1.2rem;
border: none;
border-radius: 50px;
font-weight: 600;
font-size: 0.9rem;
cursor: pointer;
}
.action-btn.secondary {
background: #f1f3f5;
color: #495057;
}
.action-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
/* Контекстное меню */
.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-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;
width: 100%;
}
.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;
}
/* Индикаторы статуса сообщений */
.message-status {
font-size: 1.1rem;
display: flex;
align-items: center;
opacity: 1;
color: white;
margin-left: 8px;
}
.message-status i {
font-size: 18px; /* Значительно увеличиваем размер иконок статуса */
}
.message-status-inside {
display: flex;
align-items: center;
margin-left: 8px;
}
.message-status-inside i {
font-size: 1.2rem;
color: #fff;
text-shadow: 0 0 2px #764ba2, 0 0 4px #667eea, 0 1px 2px #333;
/* Белая обводка для видимости на любом фоне */
filter: drop-shadow(0 0 2px #fff) drop-shadow(0 0 1px #764ba2);
}
/* Адаптивные стили */
@media (max-width: 576px) {
.message-wrapper {
max-width: 85%;
}
.message-actions {
display: none; /* Скрываем на мобильных, используем контекстное меню */
}
.action-btn {
padding: 0.6rem 1rem;
font-size: 0.85rem;
}
.back-button {
width: 32px;
height: 32px;
}
}
@media (max-width: 375px) {
.message-wrapper {
max-width: 90%;
}
.context-menu {
width: 160px;
}
}
/* Учитываем наличие нижней навигационной панели */
@media (max-height: 680px) {
.messages-container {
padding: 0.8rem;
gap: 0.6rem;
}
.message-input-container {
padding: 0.7rem;
}
.message-input {
padding: 0.6rem 0.8rem;
}
.action-btn {
padding: 0.6rem 1rem;
}
}
/* Ландшафтная ориентация на мобильных */
@media (max-height: 450px) and (orientation: landscape) {
.chat-header {
padding: 0.5rem 1rem;
height: 46px;
}
.participant-avatar {
width: 32px;
height: 32px;
}
.participant-name {
font-size: 1rem;
}
.message-input-container {
padding: 0.5rem;
}
}
/* Учет нижней мобильной навигации (примерно 60px) */
@supports (padding-bottom: env(safe-area-inset-bottom)) {
.message-input-container {
padding-bottom: calc(0.8rem + env(safe-area-inset-bottom, 0px));
}
}
</style>