Spaces:
Sleeping
Sleeping
| import { useState, useEffect } from "react"; | |
| import { useSelector, useDispatch } from "react-redux"; | |
| import { dmService } from "../services/dm.service"; | |
| import socketService from "../services/socket.service"; | |
| import { | |
| updateUserStatus, | |
| updateUserProfile, | |
| } from "../store/slices/dmSlice"; | |
| // Format relative time for DM list | |
| function formatRelativeTime(dateStr) { | |
| if (!dateStr) return ""; | |
| const date = new Date(dateStr); | |
| if (isNaN(date.getTime())) return ""; | |
| const now = new Date(); | |
| const diffMs = now.getTime() - date.getTime(); | |
| const diffSec = Math.floor(diffMs / 1000); | |
| const diffMin = Math.floor(diffSec / 60); | |
| const diffHour = Math.floor(diffMin / 60); | |
| const diffDay = Math.floor(diffHour / 24); | |
| if (diffSec < 60) return "Vừa xong"; | |
| if (diffMin < 60) return `${diffMin} phút`; | |
| if (diffHour < 24) return `${diffHour} giờ`; | |
| const isYesterday = | |
| now.getDate() - date.getDate() === 1 && | |
| now.getMonth() === date.getMonth() && | |
| now.getFullYear() === date.getFullYear(); | |
| if (isYesterday) return "Hôm qua"; | |
| return `${String(date.getDate()).padStart(2, "0")}/${String(date.getMonth() + 1).padStart(2, "0")}`; | |
| } | |
| const CONVERSATIONS_CACHE_TTL = 5 * 60 * 1000; // 5 minutes | |
| const STUDYBOT = { | |
| id: "studybot", | |
| userId: "studybot", | |
| name: "StudyBot", | |
| avatar: "🤖", | |
| lastMessage: "", | |
| hasNewMessage: false, | |
| unreadCount: 0, | |
| isBot: true, | |
| email: "studybot@vinclassroom.edu.vn", | |
| bio: "Trợ lý AI học tập của bạn", | |
| }; | |
| function matchesStudyBot(query) { | |
| if (!query) return false; | |
| const q = query.toLowerCase(); | |
| const keywords = [ | |
| "studybot", | |
| "trợ lý", | |
| "trợ ly", | |
| "ai", | |
| "bot", | |
| "học tập", | |
| "study", | |
| ]; | |
| return keywords.some((k) => q.includes(k)); | |
| } | |
| export function useDMList() { | |
| const dispatch = useDispatch(); | |
| const { conversations, onlineUsers, conversationsFetched, messages: messagesMap } = useSelector( | |
| (state) => state.dm, | |
| ); | |
| const currentUserId = useSelector((state) => state.auth.user?.id); | |
| const [searchResults, setSearchResults] = useState([]); | |
| const [searchQuery, setSearchQuery] = useState(""); | |
| const [isSearching, setIsSearching] = useState(false); | |
| const [error, setError] = useState(null); | |
| const [onlineStatus, setOnlineStatus] = useState({}); // { [userId]: { online, lastSeen } } | |
| // Conversations are now fetched globally in App.jsx after auth | |
| // This hook only reads from Redux store, no need to fetch here | |
| // Background refresh is handled by App.jsx to avoid duplicate requests | |
| // Online status now handled globally in App.jsx via Redux | |
| // This effect only syncs from Redux to local state for DMList | |
| useEffect(() => { | |
| const next = {}; | |
| onlineUsers.forEach((uid) => { | |
| next[uid] = { online: true, lastSeen: null }; | |
| }); | |
| setOnlineStatus(next); | |
| }, [onlineUsers]); | |
| // Search users via API | |
| useEffect(() => { | |
| if (!searchQuery.trim()) { | |
| setSearchResults([]); | |
| setIsSearching(false); | |
| return; | |
| } | |
| let mounted = true; | |
| const doSearch = async () => { | |
| try { | |
| setSearchResults([]); | |
| setIsSearching(true); | |
| const { data } = await dmService.searchUsers(searchQuery.trim()); | |
| if (!mounted) return; | |
| let normalized = (data.users || []).map((user) => ({ | |
| id: user.id, | |
| userId: user.id, | |
| name: user.display_name || user.email || "Unknown", | |
| avatar: user.avatar_url || null, | |
| color: user.color || null, | |
| lastMessage: "", | |
| hasNewMessage: false, | |
| unreadCount: 0, | |
| isBot: false, | |
| email: user.email || "", | |
| bio: user.bio || "", | |
| })); | |
| if (matchesStudyBot(searchQuery)) { | |
| const hasStudyBot = normalized.some( | |
| (u) => u.userId === STUDYBOT.userId, | |
| ); | |
| if (!hasStudyBot) { | |
| normalized = [STUDYBOT, ...normalized]; | |
| } | |
| } | |
| // Update Redux store with user profile info (including color) | |
| normalized.forEach((user) => { | |
| if (user.color) { | |
| dispatch( | |
| updateUserProfile({ | |
| userId: user.userId, | |
| updates: { | |
| color: user.color, | |
| display_name: user.name, | |
| avatar_url: user.avatar, | |
| }, | |
| }), | |
| ); | |
| } | |
| }); | |
| setSearchResults(normalized); | |
| } catch (err) { | |
| if (mounted) { | |
| if (matchesStudyBot(searchQuery)) { | |
| setSearchResults([STUDYBOT]); | |
| } else { | |
| setSearchResults([]); | |
| } | |
| } | |
| } finally { | |
| if (mounted) setIsSearching(false); | |
| } | |
| }; | |
| doSearch(); | |
| return () => { | |
| mounted = false; | |
| }; | |
| }, [searchQuery, dispatch]); | |
| // Helper: get latest message timestamp from Redux messages | |
| const getLatestMessageTime = (conversationId) => { | |
| const msgs = messagesMap[conversationId]; | |
| if (!msgs || msgs.length === 0) return null; | |
| return [...msgs].reduce((latest, m) => { | |
| const t = m.created_at ? new Date(m.created_at).getTime() : 0; | |
| return t > latest ? t : latest; | |
| }, 0); | |
| }; | |
| // Normalize conversations for UI and sort by latest message timestamp | |
| const normalizedConversations = [...conversations] | |
| .sort((a, b) => { | |
| const timeA = getLatestMessageTime(a.id) || (a.last_message?.created_at ? new Date(a.last_message.created_at).getTime() : 0); | |
| const timeB = getLatestMessageTime(b.id) || (b.last_message?.created_at ? new Date(b.last_message.created_at).getTime() : 0); | |
| return timeB - timeA; // newest first | |
| }) | |
| .map((conv) => { | |
| // Get latest message from Redux (sorted by created_at) | |
| const msgs = messagesMap[conv.id]; | |
| let latestMsg = null; | |
| if (msgs && msgs.length > 0) { | |
| latestMsg = [...msgs].sort((m1, m2) => { | |
| const t1 = m1.created_at ? new Date(m1.created_at).getTime() : 0; | |
| const t2 = m2.created_at ? new Date(m2.created_at).getTime() : 0; | |
| return t2 - t1; | |
| })[0]; | |
| } | |
| const apiLastMsg = conv.last_message; | |
| const msgToShow = latestMsg || apiLastMsg; | |
| const isOwn = msgToShow?.sender_id && currentUserId && String(msgToShow.sender_id) === String(currentUserId); | |
| const prefix = isOwn ? "Bạn: " : ""; | |
| const content = msgToShow?.content || ""; | |
| const lastMessageText = content ? `${prefix}${content}` : (conv.isBot ? "Trợ lý AI" : "Bắt đầu trò chuyện"); | |
| const timeStr = formatRelativeTime(msgToShow?.created_at || msgToShow?.timestamp); | |
| return { | |
| id: conv.id, | |
| userId: conv.other_user?.id, | |
| name: conv.other_user?.display_name || "Unknown", | |
| avatar: conv.other_user?.avatar_url || null, | |
| color: conv.other_user?.color || null, | |
| lastMessage: lastMessageText, | |
| lastMessageTime: timeStr, | |
| hasNewMessage: (conv.unread_count || 0) > 0, | |
| unreadCount: conv.unread_count || 0, | |
| isBot: false, | |
| email: conv.other_user?.email || "", | |
| mutualFriends: 0, | |
| conversation: conv, | |
| }; | |
| }); | |
| const filteredConversations = searchQuery.trim() | |
| ? normalizedConversations.filter((dm) => | |
| dm.name.toLowerCase().includes(searchQuery.toLowerCase()), | |
| ) | |
| : normalizedConversations; | |
| const globalSearchResults = searchQuery.trim() | |
| ? searchResults.map((user) => { | |
| const existing = normalizedConversations.find( | |
| (c) => c.userId === user.userId, | |
| ); | |
| // Merge: prefer search result data (has color) but keep conversation data if available | |
| return existing | |
| ? { ...user, ...existing, color: user.color || existing.color } | |
| : user; | |
| }) | |
| : []; | |
| const isSearchingActive = searchQuery.trim().length > 0; | |
| return { | |
| conversations: normalizedConversations, | |
| items: isSearchingActive ? globalSearchResults : filteredConversations, | |
| onlineStatus, | |
| searchQuery, | |
| setSearchQuery, | |
| isLoading: false, // handled by Redux | |
| isSearching, | |
| error, | |
| isSearchingActive, | |
| getUserOnlineStatus: (userId) => | |
| onlineStatus[userId] || { online: false, lastSeen: null }, | |
| }; | |
| } | |