Reflex/src/views/ChatListView.vue

710 lines
18 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>
<span
v-if="conversation.unreadCount > 0"
class="unread-badge"
>
{{ conversation.unreadCount < 100 ? conversation.unreadCount : '99+' }}
</span>
</div>
<!-- Conversation Info -->
<div class="conversation-info">
<div class="conversation-header">
<h3 class="user-name">{{ getOtherParticipantName(conversation) || 'Собеседник' }}</h3>
<span class="timestamp">{{ formatTimestamp(conversation.updatedAt) }}</span>
</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>
<div class="conversation-arrow">
<i class="bi-chevron-right"></i>
</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) => {
const conversation = conversations.value.find(c => c._id === message.conversationId);
if (conversation) {
// Обновляем диалог с новым сообщением
conversation.lastMessage = message;
conversation.updatedAt = new Date().toISOString();
// Если сообщение пришло от другого пользователя, увеличиваем счетчик непрочитанных
if (message.sender !== user.value._id) {
conversation.unreadCount = (conversation.unreadCount || 0) + 1;
}
// Сбрасываем статус печати
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;
}
/* Индикатор непрочитанных сообщений */
.unread-badge {
position: absolute;
top: -2px;
right: -2px;
background-color: #ff6b6b;
color: white;
font-size: 0.7rem;
border-radius: 50%;
min-width: 18px;
height: 18px;
display: flex;
align-items: center;
justify-content: center;
font-weight: 600;
box-shadow: 0 2px 5px rgba(255, 107, 107, 0.3);
border: 1px solid white;
}
/* Информация о диалоге */
.conversation-info {
flex: 1;
min-width: 0; /* Для корректной работы text-overflow */
}
.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); }
}
/* Стрелка для перехода в диалог */
.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;
}
/* Адаптивные стили */
@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));
}
}
</style>