Вопрос или проблема
Я разрабатываю приложение для чата в реальном времени, используя стек MERN с Socket.IO для обновлений в режиме реального времени. Сейчас у меня есть всплывающее окно, которое отображает всех пользователей, которое запускается по клику на кнопку “НАЧАТЬ ЧАТ”. Эта часть работает хорошо.
Тем не менее, я хочу реализовать следующее поведение для разговоров:
Отображение разговоров в боковой панели: Когда разговор инициируется или сообщение передается, разговор пользователей должен переместиться из всплывающего окна в боковую панель. Боковая панель должна отображать только активные разговоры для вошедшего пользователя.
Обновления в реальном времени: Боковая панель должна обновляться в реальном времени, чтобы отражать новые или текущие разговоры.
Текущая настройка
Пользователи уже извлечены во всплывающем окне на основе иерархии и могут инициировать разговор оттуда.
Как только разговор инициирован, мне нужно, чтобы разговор (не только пользователь) появился в боковой панели для обоих участников.
Разговоры в боковой панели должны сортироваться по роли и имени, с обновлением в реальном времени, когда разговор инициирован или сообщение отправлено.
Проблемы
Мне нужно отрегулировать мои конечные точки на сервере и, возможно, мою логику на фронтенде, чтобы отслеживать и извлекать только активные разговоры для боковой панели.
Разговоры больше не должны отображаться во всплывающем окне, как только они находятся в боковой панели.
Требуются обновления в реальном времени, чтобы оба пользователя видели активный разговор в своей боковой панели.
import React, { useState, useEffect, useRef } from 'react';
import axios from 'axios';
import Conversation from './Conversation';
import { useSocketContext } from '../../context/SocketContext';
import { useAuthContext } from '../../context/AuthContext';
import useConversation from '../../zustand/useConversation';
import SearchInput from './SearchInput';
const Conversations = () => {
const [loading, setLoading] = useState(true);
const [groupedUsers, setGroupedUsers] = useState({});
const [conversations, setConversations] = useState([]);
const [showPopup, setShowPopup] = useState(false);
const [searchTerm, setSearchTerm] = useState("");
const { socket } = useSocketContext();
const { authUser } = useAuthContext();
const { selectedConversation, setSelectedConversation } = useConversation();
const popupRef = useRef(null);
const reorderGroupsByMostRecent = (groupedUsers) => {
const allUsers = Object.values(groupedUsers).flat();
allUsers.sort((a, b) => {
const aTimestamp = new Date(a.lastMessageTimestamp || 0).getTime();
const bTimestamp = new Date(b.lastMessageTimestamp || 0).getTime();
return bTimestamp - aTimestamp;
});
const newGroupedUsers = {};
allUsers.forEach((user) => {
if (!newGroupedUsers[user.roleName]) {
newGroupedUsers[user.roleName] = [];
}
newGroupedUsers[user.roleName].push(user);
});
return newGroupedUsers;
};
useEffect(() => {
const fetchUsers = async () => {
try {
const response = await axios.get('/api/users');
if (response.data && typeof response.data === 'object') {
setGroupedUsers(response.data);
} else {
console.error("Неожиданный формат ответа API");
}
} catch (error) {
console.error("Ошибка при получении пользователей", error);
} finally {
setLoading(false);
}
};
fetchUsers();
if (socket) {
socket.on("updateSidebar", ({ senderId, receiverId, message }) => {
const targetUserId = senderId === authUser.id ? receiverId : senderId;
setGroupedUsers((prevGroupedUsers) => {
const updatedGroupedUsers = { ...prevGroupedUsers };
let updatedUser = null;
for (const role in updatedGroupedUsers) {
const userIndex = updatedGroupedUsers[role].findIndex(
(user) => user.id === targetUserId
);
if (userIndex !== -1) {
const isConversationSelected =
selectedConversation && selectedConversation.id === targetUserId;
updatedUser = {
...updatedGroupedUsers[role][userIndex],
lastMessageTimestamp: message.createdAt,
unreadMessages: isConversationSelected
? 0
: (updatedGroupedUsers[role][userIndex].unreadMessages || 0) + 1,
};
updatedGroupedUsers[role].splice(userIndex, 1);
break;
}
}
if (updatedUser) {
const userRole = updatedUser.roleName;
if (!updatedGroupedUsers[userRole]) {
updatedGroupedUsers[userRole] = [];
}
updatedGroupedUsers[userRole].unshift(updatedUser);
return reorderGroupsByMostRecent(updatedGroupedUsers);
}
return prevGroupedUsers;
});
});
socket.on("messageRead", ({ conversationId }) => {
// Обработка события messageRead
});
}
// Закрыть всплывающее окно при клике вне его
const handleClickOutside = (event) => {
if (popupRef.current && !popupRef.current.contains(event.target)) {
setShowPopup(false);
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => {
if (socket) {
socket.off("updateSidebar");
socket.off("messageRead");
}
document.removeEventListener("mousedown", handleClickOutside);
};
}, [authUser.id, socket, selectedConversation]);
// Начать или продолжить разговор с пользователем
const startConversation = (user) => {
const existingConversation = conversations.find(
(conv) => conv.id === user.id
);
if (!existingConversation) {
const newConversation = {
id: user.id,
fullName: user.fullName,
roleName: user.roleName,
lastMessageTimestamp: new Date().toISOString(),
unreadMessages: 0,
};
setConversations((prev) => [...prev, newConversation]);
}
setSelectedConversation(user); // Установить выбранный разговор
setShowPopup(false); // Закрыть всплывающее окно после начала чата
};
const roles = Object.keys(groupedUsers);
// Фильтровать пользователей на основе поискового запроса
const filteredGroupedUsers = Object.fromEntries(
roles.map(role => [
role,
groupedUsers[role].filter(user =>
user.fullName.toLowerCase().includes(searchTerm.toLowerCase())
)
])
);
return (
<div className="py-1 flex flex-col overflow-auto">
<button
onClick={() => setShowPopup(true)}
className="bg-blue-500 text-white px-4 py-2 rounded mb-4"
>
Начать новый чат
</button>
{showPopup && (
<div className="popup-overlay">
<div className="popup-content" ref={popupRef}>
<button onClick={() => setShowPopup(false)} className="close-button">
×
</button>
<h3>Выберите пользователя для начала чата</h3>
<input
type="text"
placeholder="Поиск пользователей..."
className="border rounded p-2 mb-4 w-full"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
{roles.map(role => (
<div key={role}>
<div className="font-bold divider text-base text-gray-800">{role}</div>
{filteredGroupedUsers[role].map(user => (
<div key={user.id} className="flex items-center justify-between">
<span>{user.fullName}</span>
<button
onClick={() => startConversation(user)}
className="text-blue-500"
>
Начать чат
</button>
</div>
))}
</div>
))}
</div>
</div>
)}
<SearchInput groupedUsers={groupedUsers} setGroupedUsers={setGroupedUsers} />
{conversations.map((conversation, idx) => (
<Conversation
key={conversation.id}
conversation={conversation}
lastIdx={idx === conversations.length - 1}
unreadMessages={conversation.unreadMessages}
/>
))}
{loading ? <span className="loading loading-spinner mx-auto"></span> : null}
</div>
);
};
export default Conversations;
export const sendMessage = async (req, res) => {
try {
const { message } = req.body;
const { id: receiverId } = req.params;
const senderId = req.user.id;
let fileUrl = null;
let originalFileName = null; // Поле для оригинального имени файла
// Проверить, был ли загружен файл (используя Multer для загрузок файлов)
if (req.file) {
const fileBuffer = req.file.buffer; // Получить буфер файла из multer
const cloudinaryResult = await uploadOnCloudinary(fileBuffer); // Загрузить на Cloudinary
if (cloudinaryResult) {
fileUrl = cloudinaryResult; // Сохранить URL Cloudinary
originalFileName = req.file.originalname; // Захватить оригинальное имя файла из Multer
}
}
// Проверить, существует ли разговор между отправителем и получателем, или создать новый
let conversation = await Conversation.findOne({
participants: { $all: [senderId, receiverId] },
});
if (!conversation) {
conversation = await Conversation.create({
participants: [senderId, receiverId],
});
}
// Создать новое сообщение (включает файл, если он присутствует)
const newMessage = new Message({
senderId,
receiverId,
message: message || "", // Текстовое сообщение
fileUrl, // URL Cloudinary, если файл загружен
originalFileName, // Оригинальное имя файла, если файл загружен
});
if (newMessage) {
conversation.messages.push(newMessage.id);
}
await Promise.all([conversation.save(), newMessage.save()]);
// ФУНКЦИОНАЛ СОКЕТ ИО
const receiverSocketId = getReceiverSocketId(receiverId);
if (receiverSocketId) {
// Отправить сообщение получателю
io.to(receiverSocketId).emit("newMessage", newMessage);
// Излучить событие для обновления боковой панели для всех подключенных клиентов
io.emit("updateSidebar", {
senderId,
receiverId,
message: newMessage,
});
}
// Ответить отправителю с новым сообщением
res.status(201).json(newMessage);
} catch (error) {
console.error("Ошибка в контроллере sendMessage: ", error.message);
res.status(500).json({ error: "Внутренняя ошибка сервера" });
}
};
export const getUsersForSidebar = async (req, res) => {
try {
const currentUserId = req.user?.id;
if (!currentUserId) {
return res.status(400).json({ error: "Недействительный идентификатор пользователя" });
}
const currentUser = await User.findOne({ id: currentUserId });
if (!currentUser) {
return res.status(404).json({ error: "Пользователь не найден" });
}
const currentUserRoleId = currentUser.role_id;
// Извлечение пользователей из внешнего API
const externalApiUrl = process.env.EXTERNAL_API_URL;
const apiResponse = await axios.get(externalApiUrl);
if (!apiResponse?.data?.status) {
return res.status(400).json({ error: "Не удалось получить пользователей из API" });
}
const allUsers = apiResponse.data.data.users || [];
// Извлеките существующие разговоры, в которых текущий пользователь является участником
const existingConversations = await Conversation.find({
participants: currentUserId
})
.populate({
path: 'messages',
options: { sort: { createdAt: -1 } }, // Получить все сообщения для нахождения инициатора
select: 'senderId receiverId createdAt isRead'
});
// Карта для хранения пользователей, которые инициировали разговор с текущим пользователем
const conversationInitiators = new Map();
// Обработка существующих разговоров, чтобы найти инициаторов
existingConversations.forEach(conversation => {
const firstMessage = conversation.messages[conversation.messages.length - 1]; // Получить первое сообщение (самое старое)
if (firstMessage) {
const otherParticipantId = firstMessage.senderId === currentUserId
? firstMessage.receiverId
: firstMessage.senderId;
const latestMessage = conversation.messages[0]; // Получить последнее сообщение
// Сохранить другого участника, если он инициировал разговор
if (firstMessage.senderId !== currentUserId) {
conversationInitiators.set(otherParticipantId.toString(), {
lastMessageTimestamp: latestMessage?.createdAt || conversation.updatedAt,
unreadMessages: latestMessage &&
latestMessage.receiverId === currentUserId &&
!latestMessage.isRead ? 1 : 0
});
}
}
});
// Фильтрация пользователей на основе правил иерархии и существующих разговоров
const filteredUsers = allUsers
.filter(user => {
if (!user?.id || user.id.toString() === currentUserId.toString()) return false;
// Включить пользователя, если:
// 1. Они следуют правилам иерархии (текущий пользователь может инициировать чат)
// 2. ИЛИ они инициировали разговор с текущим пользователем
return canInitiateChat(currentUserRoleId, user.role_id) ||
conversationInitiators.has(user.id.toString());
})
.map(user => {
const conversationData = conversationInitiators.get(user.id.toString());
const roleName = validRoles[user.role?.name]
? user.role.name
: roleHierarchy[user.role_id];
return {
id: user.id,
role_id: user.role_id,
fullName: user.name,
email: user.email,
profilePic: user.image_url,
lastMessageTimestamp: conversationData?.lastMessageTimestamp || null,
unreadMessages: conversationData?.unreadMessages || 0,
roleName: roleName,
wasInitiator: !!conversationData
};
});
// Сортировка пользователей: разговоры с сообщениям сначала, затем по алфавиту
const sortedUsers = filteredUsers.sort((a, b) => {
if (a.lastMessageTimestamp && !b.lastMessageTimestamp) return -1;
if (!a.lastMessageTimestamp && b.lastMessageTimestamp) return 1;
if (a.lastMessageTimestamp && b.lastMessageTimestamp) {
return new Date(b.lastMessageTimestamp).getTime() -
new Date(a.lastMessageTimestamp).getTime();
}
return a.fullName.localeCompare(b.fullName);
});
// Группировка пользователей по роли
const groupedUsers = sortedUsers.reduce((acc, user) => {
const roleName = user.roleName || `Неизвестная роль (${user.role_id})`;
if (!acc[roleName]) {
acc[roleName] = [];
}
acc[roleName].push(user);
return acc;
}, {});
res.status(200).json(groupedUsers);
} catch (error) {
console.error("Ошибка в контроллере getUsersForSidebar:", error);
res.status(500).json({ error: "Внутренняя ошибка сервера" });
}
};
Как я могу модифицировать API, чтобы извлекать только активные разговоры для боковой панели? Должен ли я использовать отдельный конечный пункт или фильтровать разговоры на фронтенде?
Как я могу убедиться, что обновления в реальном времени перемещают разговоры в боковую панель для обоих участников после инициирования?
Ответ или решение
Чтобы переместить активные беседы в боковую панель после их инициации в приложении чата на стеке MERN, вам необходимо внести изменения как в коде фронтенда, так и в бэкенде. В данном ответе мы рассмотрим, как модифицировать API для получения только активных бесед, а также обеспечим реализацию функционала в реальном времени для передвижения бесед в боковую панель для обоих участников после инициации.
1. Модификация API для получения активных бесед
Создание отдельного эндпоинта
Мы можем создать отдельный эндпоинт для получения активных бесед пользователя. Это поможет изолировать логику обработки активных разговоров и упростить интеграцию. Например, мы можем использовать следующий маршрут:
// Существующий код в контроллере
export const getActiveConversations = async (req, res) => {
try {
const userId = req.user.id;
const conversations = await Conversation.find({
participants: userId
})
.populate('messages') // Загружаем сообщения для каждой беседы
.sort({ 'updatedAt': -1 }); // Сортируем по времени обновления
res.status(200).json(conversations);
} catch (error) {
console.error("Ошибка при получении активных бесед:", error);
res.status(500).json({ error: "Внутренняя ошибка сервера" });
}
};
2. Изменения на стороне фронтенда
Вам необходимо будет изменить функциональность, чтобы обращаться к новому эндпоинту и обновлять состояние бесед в боковой панели.
Получение активных бесед
В компоненте Conversations
вы можете вызывать новый эндпоинт, чтобы загружать активные беседы пользователя при начальной загрузке компонента. Например:
useEffect(() => {
const fetchActiveConversations = async () => {
try {
const response = await axios.get('/api/conversations/active');
setConversations(response.data);
} catch (error) {
console.error("Ошибка при получении активных бесед:", error);
}
};
fetchActiveConversations();
}, []);
3. Реальные обновления для движения бесед в боковую панель
Для того чтобы поддерживать реальное взаимодействие и обновление состояния бесед, вы можете использовать Socket.IO
, как это уже реализовано в вашем приложении. Убедитесь, что сервер передает обновления, когда новый чат инициализируется. Основная логика, которую вам нужно будет добавить, выглядит следующим образом:
socket.on("updateSidebar", ({ senderId, receiverId, message }) => {
const targetUserId = senderId === authUser.id ? receiverId : senderId;
setConversations(prevConversations => {
const existingConversationIndex = prevConversations.findIndex(conv => conv.id === targetUserId);
if (existingConversationIndex > -1) {
// Обновляем существующую беседу
const updatedConversation = {
...prevConversations[existingConversationIndex],
lastMessage: message,
updatedAt: message.createdAt,
};
const newConversations = [...prevConversations];
newConversations[existingConversationIndex] = updatedConversation;
return newConversations;
} else {
// Создаем новую беседу
const newConversation = {
id: targetUserId,
lastMessage: message,
updatedAt: message.createdAt,
participants: [senderId, receiverId],
};
return [newConversation, ...prevConversations]; // Добавляем новую беседу
}
});
});
4. Удаление беседы из всплывающего окна
Также важно удалить пользователя из всплывающего окна, когда разговор инициализирован. Это можно делать во время вызова startConversation()
:
const startConversation = (user) => {
// Другие действия...
// Удаляем пользователя из списка группированных пользователей
setGroupedUsers(prevGroupedUsers => {
const updatedGroupedUsers = { ...prevGroupedUsers };
Object.keys(updatedGroupedUsers).forEach(role => {
updatedGroupedUsers[role] = updatedGroupedUsers[role].filter(u => u.id !== user.id);
});
return updatedGroupedUsers;
});
};
Заключение
Таким образом, для реализации функционала перемещения активных разговоров в боковую панель после инициации, вам необходимо внести изменения в API для получения только активных бесед, а также интегрировать логику обновления состояния через Socket.IO
. Убедитесь, что каждое изменение было протестировано, чтобы гарантировать стабильную работу вашей системы в реальном времени.