Reflex/src/views/ChatListView.vue
2025-05-21 22:13:09 +07:00

369 lines
20 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="chat-list-view container mt-4">
<h2 class="text-center mb-4">Мои диалоги</h2>
<div v-if="loading" class="d-flex justify-content-center my-5">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Загрузка диалогов...</span>
</div>
</div>
<div v-if="error" class="alert alert-danger text-center">
{{ error }}
</div>
<div v-if="!loading && conversations.length === 0 && !error" class="alert alert-info text-center">
У вас пока нет активных диалогов. <br>
Возможно, стоит кого-нибудь лайкнуть, чтобы началось общение!
</div>
<div v-if="!loading && conversations.length > 0 && !error" class="list-group">
<router-link
v-for="conversation in sortedConversations"
:key="conversation._id"
:to="{ name: 'ChatView', params: { conversationId: conversation._id } }"
class="list-group-item list-group-item-action d-flex justify-content-between align-items-center mb-2 shadow-sm rounded"
>
<div class="d-flex align-items-center">
<!-- === ОТОБРАЖЕНИЕ ФОТО/ЗАГЛУШКИ === -->
<div class="image-placeholder-container me-3">
<img
v-if="getOtherParticipant(conversation.participants)?.mainPhotoUrl"
:src="getParticipantAvatar(getOtherParticipant(conversation.participants))"
alt="Avatar"
class="rounded-circle user-photo"
@error="onImageError($event, getOtherParticipant(conversation.participants))"
/>
<div v-else class="no-photo-placeholder rounded-circle d-flex align-items-center justify-content-center">
<i class="bi bi-person-fill"></i>
</div>
</div>
<!-- ================================ -->
<div>
<h5 class="mb-1">{{ getOtherParticipant(conversation.participants)?.name || 'Неизвестный пользователь' }}</h5>
<small class="text-muted last-message-text">
{{ conversation.lastMessage ? formatLastMessage(conversation.lastMessage) : 'Нет сообщений' }}
</small>
</div>
</div>
<div class="text-end">
<small class="text-muted d-block">{{ formatTimestamp(conversation.updatedAt) }}</small>
<span v-if="getUnreadCount(conversation) > 0" class="badge bg-primary rounded-pill unread-indicator">
{{ getUnreadCount(conversation) }}
</span>
</div>
</router-link>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, computed, onUnmounted } from 'vue';
import api from '@/services/api';
import { useAuth } from '@/auth'; // Для получения ID текущего пользователя
import router from '@/router'; // Если понадобится редирект
import { getSocket, connectSocket } from '@/services/socketService'; // ИСПРАВЛЕННЫЙ ПУТЬ
const { user: currentUser, isAuthenticated } = useAuth(); // Получаем текущего пользователя
const conversations = ref([]);
const loading = ref(true);
const error = ref('');
// Добавляем ref для хранения количества непрочитанных сообщений
const unreadMessageCounts = ref({}); // { conversationId: count }
const defaultAvatar = '/img/default-avatar.png'; // Путь к заглушке аватара в public/img/
let socket = null; // Для прослушивания обновлений о прочтении
const fetchConversations = async () => {
loading.value = true;
error.value = '';
try {
console.log('[ChatListView] Запрос списка диалогов...');
const response = await api.getUserConversations();
conversations.value = response.data;
console.log('[ChatListView] Диалоги загружены:', conversations.value);
} catch (err) {
console.error('[ChatListView] Ошибка при загрузке диалогов:', err.response ? err.response.data : err.message);
error.value = (err.response?.data?.message) || 'Не удалось загрузить диалоги.';
if (err.response && err.response.status === 401) {
// Если 401, возможно, нужно перенаправить на логин
// const { logout } = useAuth(); // Если logout не импортирован
// await logout();
}
} finally {
loading.value = false;
}
};
// Сортируем диалоги по дате последнего сообщения/обновления (новые сверху)
const sortedConversations = computed(() => {
return [...conversations.value].sort((a, b) => {
// Бэкенд уже сортирует по updatedAt, но если lastMessageTimestamp есть, он точнее
const dateA = new Date(a.lastMessage?.createdAt || a.updatedAt);
const dateB = new Date(b.lastMessage?.createdAt || b.updatedAt);
return dateB - dateA;
});
});
// Обновленная функция для подсчета непрочитанных сообщений
const getUnreadCount = (conversation) => {
const countFromSocketLogic = unreadMessageCounts.value[conversation._id];
if (countFromSocketLogic && countFromSocketLogic > 0) {
return countFromSocketLogic;
}
// Fallback к старой логике, если новый счетчик 0 (или не инициализирован для старых чатов),
// но lastMessage не прочитано текущим пользователем.
if (currentUser.value && conversation.lastMessage &&
conversation.lastMessage.sender?._id !== currentUser.value?._id &&
(!conversation.lastMessage.readBy || !conversation.lastMessage.readBy.includes(currentUser.value._id))) {
return 1; // Показываем, что есть хотя бы одно непрочитанное (последнее)
}
return 0;
};
// Получаем другого участника диалога
const getOtherParticipant = (participants) => {
if (!currentUser.value || !participants || participants.length < 2) return null;
return participants.find(p => p._id !== currentUser.value._id);
};
// Новая функция для получения URL аватара (исправленная)
const getParticipantAvatar = (participant) => {
if (participant && participant.mainPhotoUrl && typeof participant.mainPhotoUrl === 'string' && participant.mainPhotoUrl.trim() !== '') {
return participant.mainPhotoUrl.trim();
}
// Важно: если mainPhotoUrl нет или он невалиден, мы НЕ возвращаем defaultAvatar здесь,
// так как v-else в шаблоне должен показать заглушку <i class="bi bi-person-fill"></i>
// Однако, onImageError все еще может использовать defaultAvatar, если URL есть, но картинка не грузится.
return null; // или undefined, чтобы v-if="...mainPhotoUrl" работал как ожидается
};
// Форматирование последнего сообщения (кратко)
const formatLastMessage = (lastMsg) => {
if (!lastMsg || !lastMsg.text) return 'Нет сообщений';
const prefix = lastMsg.sender?._id === currentUser.value?._id ? 'Вы: ' : '';
return prefix + (lastMsg.text.length > 30 ? lastMsg.text.substring(0, 27) + '...' : lastMsg.text);
};
// Форматирование времени последнего сообщения/обновления
const formatTimestamp = (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 date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
} else if (date.toDateString() === yesterday.toDateString()) {
return 'Вчера';
} else {
return date.toLocaleDateString([], { day: '2-digit', month: 'short' });
}
};
const onImageError = (event, participantOnError) => {
console.warn("[ChatListView] Не удалось загрузить изображение:", event.target.src, "для пользователя:", participantOnError?.name);
// Устанавливаем src на заглушку из public/img, если фото не загрузилось.
// Это для случая, когда mainPhotoUrl был, но оказался битым.
// Если mainPhotoUrl изначально не было, сработает v-else с иконкой.
event.target.src = defaultAvatar;
// Можно добавить класс, чтобы скрыть img и показать div-заглушку, если это необходимо,
// но обычно @error на img и последующая замена src на defaultAvatar достаточны.
// Однако, для консистенции с SwipeView, лучше убедиться, что заглушка-иконка показывается.
// Для этого можно попробовать скрыть img и показать соседний div с иконкой,
// но это усложнит логику, так как v-if/v-else уже управляют этим.
// Простейший вариант - просто заменить на defaultAvatar.png.
// Если хотим именно иконку, то нужно будет менять состояние, чтобы v-else сработало.
// Например, установить participantOnError.mainPhotoUrl = null;
// Это вызовет обновление DOM и показ блока v-else.
if (participantOnError) {
// Ищем диалог, где этот участник, и модифицируем его mainPhotoUrl,
// чтобы вызвать реактивное обновление и показ заглушки-иконки
const conversationToUpdate = conversations.value.find(conv =>
conv.participants.some(p => p._id === participantOnError._id)
);
if (conversationToUpdate) {
const participantInConversation = conversationToUpdate.participants.find(p => p._id === participantOnError._id);
if (participantInConversation) {
participantInConversation.mainPhotoUrl = null; // Это вызовет показ v-else
}
}
}
};
// Обработчик события messagesRead для обновления UI списка чатов
const handleMessagesReadInList = ({ conversationId: readConversationId, readerId }) => {
// Если текущий пользователь прочитал сообщения в каком-то чате (событие от ChatView через сервер)
// или если собеседник прочитал наши сообщения.
// Нам нужно обновить состояние `conversations` чтобы убрать индикатор непрочитанных.
const convIndex = conversations.value.findIndex(c => c._id === readConversationId);
if (convIndex > -1) {
const conv = conversations.value[convIndex];
// Если Я (currentUser) прочитал сообщения собеседника в этом диалоге
if (readerId === currentUser.value?._id && conv.lastMessage && conv.lastMessage.sender?._id !== currentUser.value?._id) {
if (!conv.lastMessage.readBy) conv.lastMessage.readBy = [];
if (!conv.lastMessage.readBy.includes(readerId)) {
conv.lastMessage.readBy.push(readerId);
// conversations.value[convIndex] = { ...conv }; // Вызываем реактивность
// Vue 3 должен автоматически отслеживать изменения в массиве/объекте
}
}
// Если Собеседник прочитал НАШИ сообщения (это больше для ChatView, но если хотим обновить тут что-то)
// В данном контексте (список чатов), это событие в основном означает, что МЫ прочитали,
// и если у нас был индикатор непрочитанных от собеседника, он должен исчезнуть.
// Перезапрос fetchConversations() может быть слишком тяжелым.
// Лучше, если сервер присылает обновленный объект диалога или мы мутируем локально.
console.log(`[ChatListView] Messages read in conversation ${readConversationId}, reader: ${readerId}. Updating UI.`);
// Форсируем обновление, если Vue не подхватывает изменение в глубине объекта
conversations.value = [...conversations.value];
}
};
onMounted(() => {
if (isAuthenticated.value) {
fetchConversations(); // unreadMessageCounts будет инициализирован или очищен внутри fetchConversations при необходимости
socket = getSocket();
if (!socket) {
socket = connectSocket();
}
if (socket) {
// Слушаем, если сообщения были прочитаны в каком-либо из диалогов
socket.on('messagesRead', ({ conversationId: readConversationId, readerId }) => {
// Это событие от сервера после того, как кто-то прочитал сообщения
// Нас интересует, когда ТЕКУЩИЙ пользователь прочитал сообщения в каком-то диалоге
if (readerId === currentUser.value?._id) {
console.log(`[ChatListView] Current user read messages in ${readConversationId}. Resetting unread count.`);
unreadMessageCounts.value[readConversationId] = 0;
}
// Обновляем состояние readBy для lastMessage для консистентности отображения
// и для корректной работы fallback-логики в getUnreadCount
const convIndex = conversations.value.findIndex(c => c._id === readConversationId);
if (convIndex > -1) {
const conv = conversations.value[convIndex];
if (conv.lastMessage) {
let changed = false;
if (readerId === currentUser.value?._id && conv.lastMessage.sender?._id !== currentUser.value?._id) { // Я прочитал сообщение другого
if (!conv.lastMessage.readBy) conv.lastMessage.readBy = [];
if (!conv.lastMessage.readBy.includes(readerId)) {
conv.lastMessage.readBy.push(readerId);
changed = true;
}
} else if (conv.lastMessage.sender?._id === currentUser.value?._id && readerId !== currentUser.value?._id) { // Другой прочитал мое сообщение
if (!conv.lastMessage.readBy) conv.lastMessage.readBy = [];
if (!conv.lastMessage.readBy.includes(readerId)) {
conv.lastMessage.readBy.push(readerId);
changed = true;
}
}
if (changed) {
// Форсируем обновление, если Vue не подхватывает глубокие изменения
conversations.value = [...conversations.value];
}
}
}
});
socket.on('getMessage', (newMessage) => {
const convIndex = conversations.value.findIndex(c => c._id === newMessage.conversationId);
if (convIndex > -1) {
const conversation = conversations.value[convIndex];
conversation.lastMessage = newMessage;
conversation.updatedAt = newMessage.createdAt; // Обновляем время для сортировки
// Если сообщение не от текущего пользователя и для этого диалога
if (newMessage.sender?._id !== currentUser.value?._id) {
unreadMessageCounts.value[newMessage.conversationId] =
(unreadMessageCounts.value[newMessage.conversationId] || 0) + 1;
}
// Пересортировываем массив диалогов, чтобы актуальный чат поднялся вверх
conversations.value.sort((a, b) => {
const dateA = new Date(a.lastMessage?.createdAt || a.updatedAt);
const dateB = new Date(b.lastMessage?.createdAt || b.updatedAt);
return dateB - dateA;
});
} else {
// Если пришло сообщение для НОВОГО диалога, которого еще нет в списке,
// лучшим решением будет перезапросить все диалоги, чтобы он корректно отобразился.
// Это может случиться, если диалог был создан другим пользователем только что.
console.log(`[ChatListView] Received message for a new or unknown conversation ${newMessage.conversationId}. Refetching conversations.`);
fetchConversations();
}
});
}
} else {
error.value = "Пожалуйста, войдите в систему, чтобы просматривать диалоги.";
loading.value = false;
// Можно перенаправить на логин здесь или положиться на router guard
// router.push('/login');
}
});
// Добавляем onUnmounted для отписки от событий сокета
onUnmounted(() => {
if (socket) {
socket.off('messagesRead'); // Используем то же имя события, что и в onMounted
socket.off('getMessage');
}
});
</script>
<style scoped>
.chat-list-view {
max-width: 800px;
margin: auto;
}
.list-group-item {
transition: background-color 0.2s ease-in-out;
}
.list-group-item:hover {
background-color: #f8f9fa;
}
.last-message-text {
display: -webkit-box;
-webkit-line-clamp: 1;
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
font-size: 0.9em;
line-clamp: 1; /* Стандартное свойство */
}
.image-placeholder-container {
width: 50px;
height: 50px;
position: relative;
background-color: #e9ecef; /* Фон для контейнера, если фото не покроет его полностью или для заглушки */
border-radius: 50%; /* Делаем контейнер круглым */
overflow: hidden; /* Обрезаем все, что выходит за круглые границы */
}
.user-photo, .no-photo-placeholder {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
object-fit: cover; /* Для img */
}
.no-photo-placeholder i {
font-size: 2.5rem; /* Размер иконки, подберите по вкусу для 50px контейнера */
color: #adb5bd;
}
.list-group-item .badge {
font-size: 0.8em;
}
.unread-indicator {
/* Можно сделать более заметным, например, жирным шрифтом или цветом */
font-weight: bold;
}
</style>