Reflex/src/views/ChatListView.vue
Professional 214db82ba1 фикс
2025-05-25 00:05:52 +07:00

799 lines
21 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-chatlist-view">
<!-- Фиксированный хедер -->
<div class="chatlist-header">
<h2 class="section-title">Диалоги</h2>
</div>
<!-- Основное содержимое -->
<main class="chatlist-content">
<!-- Loading State -->
<div v-if="loading" class="loading-section">
<div class="loading-spinner">
<div class="spinner"></div>
<p>Загрузка диалогов...</p>
</div>
</div>
<!-- Conversations List -->
<div v-if="!loading && isAuthenticated" class="conversations-container">
<!-- Empty State -->
<div v-if="conversations.length === 0" class="empty-chats">
<div class="empty-chats-content">
<i class="bi-chat-dots"></i>
<h3>Нет диалогов</h3>
<p>У вас пока нет активных диалогов. Начните общение с теми, кто вам понравился!</p>
<router-link to="/swipe" class="action-btn primary">Найти собеседников</router-link>
</div>
</div>
<!-- Conversations List -->
<div v-else class="conversations-list">
<router-link
v-for="conversation in conversationsSorted"
:key="conversation._id"
:to="`/chat/${conversation._id}`"
class="conversation-card"
>
<!-- User Photo -->
<div class="user-photo-container">
<img
v-if="getOtherParticipant(conversation)?.mainPhotoUrl"
:src="getOtherParticipant(conversation)?.mainPhotoUrl"
:alt="getOtherParticipantName(conversation) || 'Фото собеседника'"
class="user-photo"
/>
<div v-else class="user-photo-placeholder">
<i class="bi-person"></i>
</div>
</div>
<!-- Conversation Info -->
<div class="conversation-info">
<div class="conversation-header">
<h3 class="user-name">{{ getOtherParticipantName(conversation) || 'Собеседник' }}</h3>
<div class="timestamp-container">
<span class="timestamp">{{ formatTimestamp(conversation.updatedAt) }}</span>
<!-- Индикатор теперь с абсолютным позиционированием -->
<span
v-if="conversation.unreadCount > 0"
class="unread-badge-floating"
>
{{ conversation.unreadCount < 100 ? conversation.unreadCount : '99+' }}
</span>
</div>
</div>
<p class="last-message">
<span v-if="conversation.typing" class="typing-text">
<span class="typing-animation">
<span class="dot"></span>
<span class="dot"></span>
<span class="dot"></span>
</span>
печатает...
</span>
<template v-else>
{{ formatLastMessage(conversation) }}
</template>
</p>
</div>
<!-- Right side: только стрелка -->
<div class="conversation-right">
<div class="conversation-arrow">
<i class="bi-chevron-right"></i>
</div>
</div>
</router-link>
</div>
</div>
<!-- No Auth State -->
<div v-if="!isAuthenticated && !loading" class="not-authenticated-section">
<div class="not-auth-card">
<i class="bi-person-lock"></i>
<h3>Доступ ограничен</h3>
<p>Пожалуйста, войдите в систему, чтобы просматривать диалоги.</p>
<router-link to="/login" class="action-btn primary">Войти в систему</router-link>
</div>
</div>
</main>
</div>
</template>
<script setup>
import { ref, onMounted, computed, onUnmounted, watch } from 'vue';
import api from '@/services/api';
import { useAuth } from '@/auth';
import { getSocket, connectSocket } from '@/services/socketService';
const { user, isAuthenticated } = useAuth();
// Состояния
const conversations = ref([]);
const loading = ref(true);
const error = ref('');
let socket = null;
// Сортировка диалогов
const conversationsSorted = computed(() => {
return [...conversations.value].sort((a, b) => {
// Диалоги с непрочитанными сообщениями в начало
if (a.unreadCount !== b.unreadCount) {
return b.unreadCount - a.unreadCount;
}
// Диалоги, где собеседник печатает, в начало
if (a.typing !== b.typing) {
return a.typing ? -1 : 1;
}
// Сортировка по времени обновления (последнее сообщение)
return new Date(b.updatedAt) - new Date(a.updatedAt);
});
});
// Получение данных
const fetchConversations = async () => {
if (!isAuthenticated.value) {
loading.value = false;
return;
}
loading.value = true;
error.value = '';
try {
const response = await api.getUserConversations();
conversations.value = response.data.map(conv => ({
...conv,
typing: false
}));
console.log('[ChatListView] Получено диалогов:', conversations.value.length);
} catch (err) {
console.error('[ChatListView] Ошибка при загрузке диалогов:', err.response ? err.response.data : err);
error.value = err.response?.data?.message || 'Не удалось загрузить диалоги';
} finally {
loading.value = false;
}
};
// Вспомогательные методы
const getOtherParticipant = (conversation) => {
if (!conversation || !conversation.participants || !user.value) return null;
return conversation.participants.find(p => p._id !== user.value._id);
};
const getOtherParticipantName = (conversation) => {
const participant = getOtherParticipant(conversation);
return participant ? participant.name : 'Собеседник';
};
const formatTimestamp = (timestamp) => {
if (!timestamp) return '';
const date = new Date(timestamp);
const now = new Date();
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const yesterday = new Date(today);
yesterday.setDate(yesterday.getDate() - 1);
if (date >= today) {
// Сегодня: показываем только время
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
} else if (date >= yesterday) {
// Вчера: показываем "вчера"
return 'вчера';
} else if (now.getFullYear() === date.getFullYear()) {
// В этом году: день и месяц
return date.toLocaleDateString([], { day: 'numeric', month: 'short' });
} else {
// Другой год: день, месяц, год
return date.toLocaleDateString([], { day: 'numeric', month: 'short', year: 'numeric' });
}
};
const formatLastMessage = (conversation) => {
if (!conversation.lastMessage) {
return 'Нет сообщений';
}
let text = conversation.lastMessage.text || '';
if (text.length > 30) {
text = text.substring(0, 30) + '...';
}
// Если последнее сообщение от текущего пользователя, добавляем "Вы: "
if (conversation.lastMessage.sender === user.value._id) {
text = 'Вы: ' + text;
}
return text;
};
// Обработка событий
const handleNewMessageEvent = (message) => {
console.log('[ChatListView] Получено новое сообщение:', {
sender: message.sender,
currentUser: user.value._id,
conversationId: message.conversationId
});
const conversation = conversations.value.find(c => c._id === message.conversationId);
if (conversation) {
// Обновляем диалог с новым сообщением
conversation.lastMessage = message;
conversation.updatedAt = new Date().toISOString();
// ВАЖНО: Правильно сравниваем ID отправителя
// message.sender может быть как объектом, так и строкой ID
const senderId = typeof message.sender === 'object' ? message.sender._id : message.sender;
// ВАЖНО: Увеличиваем счетчик ТОЛЬКО если сообщение пришло от другого пользователя
if (senderId !== user.value._id) {
// Проверяем, находится ли пользователь в текущем чате
const currentRoute = window.location.pathname;
const isInThisChat = currentRoute === `/chat/${message.conversationId}`;
if (!isInThisChat) {
// Увеличиваем счетчик только если пользователь НЕ находится в этом чате
conversation.unreadCount = (conversation.unreadCount || 0) + 1;
console.log('[ChatListView] Увеличен счетчик непрочитанных для диалога:', message.conversationId, 'новый счетчик:', conversation.unreadCount);
} else {
console.log('[ChatListView] Пользователь находится в этом чате, счетчик не увеличиваем');
}
} else {
console.log('[ChatListView] Сообщение от текущего пользователя, счетчик не изменяется');
}
// Сбрасываем статус печати
conversation.typing = false;
}
};
const handleMessagesReadEvent = ({ conversationId, readerId }) => {
// Обрабатываем только если текущий пользователь прочитал сообщения
if (readerId === user.value._id) {
const conversation = conversations.value.find(c => c._id === conversationId);
if (conversation) {
conversation.unreadCount = 0;
}
}
};
const handleTypingEvent = ({ conversationId, senderId }) => {
// Если тот, кто печатает - другой пользователь
if (senderId !== user.value._id) {
const conversation = conversations.value.find(c => c._id === conversationId);
if (conversation) {
conversation.typing = true;
}
}
};
const handleStopTypingEvent = ({ conversationId, senderId }) => {
if (senderId !== user.value._id) {
const conversation = conversations.value.find(c => c._id === conversationId);
if (conversation) {
conversation.typing = false;
}
}
};
// Жизненный цикл
onMounted(async () => {
await fetchConversations();
if (isAuthenticated.value) {
// Подключаем сокет
socket = getSocket();
if (!socket) {
socket = connectSocket();
}
// Подписываемся на события
if (socket) {
socket.on('getMessage', handleNewMessageEvent);
socket.on('messagesRead', handleMessagesReadEvent);
socket.on('userTyping', handleTypingEvent);
socket.on('userStopTyping', handleStopTypingEvent);
// Присоединение к комнате для уведомлений
if (user.value?._id) {
socket.emit('joinNotificationRoom', user.value._id);
}
}
}
});
onUnmounted(() => {
if (socket) {
socket.off('getMessage', handleNewMessageEvent);
socket.off('messagesRead', handleMessagesReadEvent);
socket.off('userTyping', handleTypingEvent);
socket.off('userStopTyping', handleStopTypingEvent);
if (user.value?._id) {
socket.emit('leaveNotificationRoom', user.value._id);
}
}
});
// Следить за статусом аутентификации
watch(() => isAuthenticated.value, (newVal) => {
if (newVal) {
fetchConversations();
} else {
conversations.value = [];
loading.value = false;
// Можно перенаправить на логин здесь или положиться на router guard
// router.push('/login');
}
});
// Функция для определения правильного склонения слова "диалог"
const getDialogsCountText = (count) => {
const remainder10 = count % 10;
const remainder100 = count % 100;
if (remainder10 === 1 && remainder100 !== 11) {
return 'диалог';
} else if ([2, 3, 4].includes(remainder10) && ![12, 13, 14].includes(remainder100)) {
return 'диалога';
} else {
return 'диалогов';
}
};
</script>
<style scoped>
/* Основные стили */
.app-chatlist-view {
display: flex;
flex-direction: column;
height: 100vh;
width: 100%;
position: relative;
background-color: #f8f9fa;
background-image: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
/* Хедер */
.chatlist-header {
background: rgba(33, 33, 60, 0.9);
backdrop-filter: blur(10px);
padding: 0.8rem 0;
text-align: center;
color: white;
position: sticky;
top: 0;
z-index: 10;
height: var(--header-height, 56px);
display: flex;
align-items: center;
justify-content: center;
}
.section-title {
margin: 0;
font-size: 1.3rem;
font-weight: 600;
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
}
/* Основная область контента */
.chatlist-content {
flex: 1;
display: flex;
flex-direction: column;
position: relative;
overflow-x: hidden;
}
/* Состояния загрузки */
.loading-section {
display: flex;
justify-content: center;
align-items: center;
flex: 1;
padding: 1rem;
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;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
/* Контейнер диалогов */
.conversations-container {
flex: 1;
display: flex;
flex-direction: column;
padding: 0.5rem;
}
/* Пустое состояние */
.empty-chats {
display: flex;
justify-content: center;
align-items: center;
flex: 1;
padding: 1rem;
}
.empty-chats-content {
background: white;
border-radius: 20px;
padding: 2rem;
text-align: center;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2);
max-width: 400px;
width: 100%;
}
.empty-chats-content i {
font-size: 3rem;
margin-bottom: 1rem;
color: #6c757d;
}
.empty-chats-content h3 {
color: #343a40;
margin-bottom: 0.5rem;
}
.empty-chats-content p {
margin-bottom: 1.5rem;
color: #6c757d;
}
.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);
}
/* Список диалогов */
.conversations-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
overflow-y: auto;
padding-bottom: 0.5rem;
-webkit-overflow-scrolling: touch;
}
/* Карточка диалога */
.conversation-card {
display: flex;
align-items: center;
padding: 0.8rem;
background: white;
border-radius: 12px;
text-decoration: none;
color: inherit;
transition: all 0.2s ease;
gap: 1rem;
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.08);
}
.conversation-card:active {
transform: scale(0.98);
background-color: #f8f9fa;
}
/* Аватар */
.user-photo-container {
width: 50px;
height: 50px;
border-radius: 50%;
overflow: hidden;
position: relative;
flex-shrink: 0;
}
.user-photo {
width: 100%;
height: 100%;
object-fit: cover;
}
.user-photo-placeholder {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background: #e9ecef;
color: #6c757d;
}
.user-photo-placeholder i {
font-size: 1.5rem;
}
/* Правая часть диалога с счетчиком и стрелкой */
.conversation-right {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
flex-shrink: 0;
}
/* Индикатор непрочитанных сообщений - теперь в правой части */
.conversation-right .unread-badge {
position: static;
background: linear-gradient(135deg, #ff6b6b, #ff5252);
color: white;
font-size: 0.65rem;
font-weight: 700;
border-radius: 12px;
min-width: 22px;
height: 22px;
display: flex;
align-items: center;
justify-content: center;
padding: 0 8px;
box-shadow: 0 3px 8px rgba(255, 107, 107, 0.4);
border: 2px solid white;
animation: subtle-pulse 2s ease-in-out infinite;
}
/* Стрелка для перехода в диалог */
.conversation-arrow {
display: flex;
align-items: center;
color: #adb5bd;
transition: transform 0.2s ease;
}
.conversation-card:active .conversation-arrow {
transform: translateX(2px);
color: #667eea;
}
/* Неавторизованное состояние */
.not-authenticated-section {
flex: 1;
display: flex;
justify-content: center;
align-items: center;
padding: 1rem;
}
.not-auth-card {
background: white;
border-radius: 20px;
padding: 2rem;
text-align: center;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2);
max-width: 400px;
width: 100%;
}
.not-auth-card i {
font-size: 3rem;
margin-bottom: 1rem;
color: #6c757d;
}
.not-auth-card h3 {
color: #343a40;
margin-bottom: 0.5rem;
}
.not-auth-card p {
margin-bottom: 1.5rem;
color: #6c757d;
}
/* Мягкая анимация пульсации для непрочитанных сообщений */
@keyframes subtle-pulse {
0%, 100% {
transform: scale(1);
box-shadow: 0 3px 8px rgba(255, 107, 107, 0.4);
}
50% {
transform: scale(1.05);
box-shadow: 0 4px 12px rgba(255, 107, 107, 0.6);
}
}
/* Информация о диалоге */
.conversation-info {
flex: 1;
min-width: 0; /* Для корректной работы text-overflow */
}
/* Контейнер времени и индикатора */
.timestamp-container {
position: relative;
display: flex;
flex-direction: column;
align-items: flex-end;
/* Убираем gap, чтобы индикатор не создавал пространства */
}
.timestamp {
font-size: 0.75rem;
color: #6c757d;
flex-shrink: 0;
}
/* Плавающий индикатор - абсолютное позиционирование */
.unread-badge-floating {
position: absolute;
top: 100%; /* Позиционируем под временем */
right: 0;
margin-top: 2px; /* Небольшой отступ от времени */
background: linear-gradient(135deg, #ff6b6b, #ff5252);
color: white;
font-size: 0.6rem;
font-weight: 700;
border-radius: 10px;
min-width: 18px;
height: 18px;
display: flex;
align-items: center;
justify-content: center;
padding: 0 6px;
box-shadow: 0 2px 6px rgba(255, 107, 107, 0.4);
border: 1.5px solid white;
animation: subtle-pulse 2s ease-in-out infinite;
z-index: 1;
}
/* Адаптивные стили */
@media (max-width: 576px) {
.conversation-card {
padding: 0.7rem;
}
.user-photo-container {
width: 45px;
height: 45px;
}
.user-name {
font-size: 0.95rem;
}
.last-message {
font-size: 0.8rem;
}
}
@media (max-width: 375px) {
.conversation-card {
padding: 0.6rem;
}
.user-photo-container {
width: 40px;
height: 40px;
}
.user-photo-placeholder i {
font-size: 1.3rem;
}
}
/* Ландшафтная ориентация */
@media (max-height: 450px) and (orientation: landscape) {
.chatlist-header {
height: 46px;
padding: 0.5rem 0;
}
.section-title {
font-size: 1.1rem;
}
.conversations-container {
padding: 0.3rem;
}
.conversation-card {
padding: 0.5rem 0.7rem;
}
}
/* Учитываем Safe Area для iPhone X+ */
@supports (padding-bottom: env(safe-area-inset-bottom)) {
.conversations-list {
padding-bottom: calc(0.5rem + env(safe-area-inset-bottom, 0px));
}
}
/* Стили для элементов диалога, которые были в старой структуре */
.conversation-header {
display: flex;
justify-content: space-between;
align-items: baseline;
margin-bottom: 0.25rem;
}
.user-name {
margin: 0;
font-size: 1rem;
font-weight: 600;
color: #212529;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.timestamp {
font-size: 0.75rem;
color: #6c757d;
flex-shrink: 0;
}
.last-message {
margin: 0;
font-size: 0.85rem;
color: #6c757d;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.typing-text {
display: flex;
align-items: center;
gap: 0.4rem;
font-style: italic;
color: #667eea;
}
.typing-animation {
display: flex;
gap: 3px;
align-items: center;
}
.dot {
width: 3px;
height: 3px;
background-color: #667eea;
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(-3px); }
}
</style>