From 9640f896d47f7349c04ebffeb15dcf66633eac64 Mon Sep 17 00:00:00 2001 From: Jeerasak Kajonrattanavanit Date: Sat, 19 Apr 2025 22:38:11 +0700 Subject: [PATCH 1/5] feat:fetch online user --- client/src/context/chat-context.tsx | 22 ++++++++++++++++++++++ server/src/services/users/controller.ts | 23 +++++++++++++++++++++++ server/src/services/users/route.ts | 9 +++++++-- 3 files changed, 52 insertions(+), 2 deletions(-) diff --git a/client/src/context/chat-context.tsx b/client/src/context/chat-context.tsx index e7dc2f3..0580a57 100644 --- a/client/src/context/chat-context.tsx +++ b/client/src/context/chat-context.tsx @@ -54,6 +54,7 @@ export const ChatProvider: React.FC<{ children: React.ReactNode }> = ({ addOrUpdateMessageAtLast, removeMessage, } = useChatMessagesHelper(messages, setMessages) + const [onlineUsers, setOnlineUsers] = useState>(new Set()) const socketRef = useRef(null) const [loadingChats, setLoadingChats] = useState(false) const [loadingMessages, setLoadingMessages] = useState(false) @@ -191,6 +192,27 @@ export const ChatProvider: React.FC<{ children: React.ReactNode }> = ({ // Listen for direct message events if (!socketRef.current) return + socketRef.current.on('socket-room-online-status', (res: any) => { + // console.log('Socket Room Online Status:', res) + if (res.status === 'ok') { + const fetchOnlineUsers = async () => { + try { + const response = await fetch( + `${import.meta.env.VITE_API_URL}/api/users/online`, + ) + if (!response.ok) { + throw new Error('Failed to fetch online users') + } + const data = await response.json() + console.log('Online users:', data) + // setOnlineUsers(new Set(data.map((user: any) => user.id))) + }catch (error) { + console.error('Error fetching online users:', error) + } + } + fetchOnlineUsers() + }}) + socketRef.current.on('socket-room-message', (res) => { console.log('Message received:', res) if (res.status === 'ok') { diff --git a/server/src/services/users/controller.ts b/server/src/services/users/controller.ts index 4873673..a90e637 100644 --- a/server/src/services/users/controller.ts +++ b/server/src/services/users/controller.ts @@ -291,3 +291,26 @@ export const updateUserOffline = async (userId: number): Promise => { }); }; +export const getAllOnlineUsers = async (): Promise => { + const users = await prisma.user.findMany({ + where: { isOnline: true }, + select: { + id: true, + email: true, + username: true, + registeredAt: true, + lastLoginAt: true, + isOnline: true, + }, + }); + + return users.map((user) => ({ + id: user.id, + email: user.email, + username: user.username, + registeredAt: user.registeredAt, + lastLoginAt: user.lastLoginAt, + isOnline: user.isOnline, + })); +}; + diff --git a/server/src/services/users/route.ts b/server/src/services/users/route.ts index 320b1bb..11d6e94 100644 --- a/server/src/services/users/route.ts +++ b/server/src/services/users/route.ts @@ -1,7 +1,7 @@ import * as express from 'express'; import { body, validationResult } from 'express-validator'; -import { registerUser, getUserById, getUserByUsername, loginUser, updateUsername, getChat, getAllUsers } from './controller'; +import { registerUser, getUserById, getUserByUsername, loginUser, updateUsername, getChat, getAllUsers, getAllOnlineUsers } from './controller'; import { validateRegisterUser, protect } from '@/middleware/auth'; import { getSignedJwtToken } from './utils'; @@ -12,6 +12,11 @@ router.get('/', async (req: express.Request, res: express.Response): Promise => { + const onlineUsers = await getAllOnlineUsers(); + res.status(200).json(onlineUsers); +}); + /** * @swagger * /api/users: @@ -374,4 +379,4 @@ router.put( } ); -export default router; \ No newline at end of file +export default router; From d5c5e47b06adfa5dcec7bdba4ceb82e123d05ec1 Mon Sep 17 00:00:00 2001 From: Jeerasak Kajonrattanavanit Date: Sun, 20 Apr 2025 00:12:50 +0700 Subject: [PATCH 2/5] feat:online user useContext --- client/src/components/chat/chat-sidebar.tsx | 7 +++- client/src/context/chat-context.tsx | 42 ++++++++++++--------- 2 files changed, 30 insertions(+), 19 deletions(-) diff --git a/client/src/components/chat/chat-sidebar.tsx b/client/src/components/chat/chat-sidebar.tsx index 49f5f7c..935a252 100644 --- a/client/src/components/chat/chat-sidebar.tsx +++ b/client/src/components/chat/chat-sidebar.tsx @@ -10,6 +10,7 @@ import { X, } from 'lucide-react' import { useQuery } from '@tanstack/react-query' +import { formatDistanceToNow } from 'date-fns' import type { User } from '@/lib/types' import { Button } from '@/components/ui/button' import { @@ -27,8 +28,7 @@ import { Avatar, AvatarFallback } from '@/components/ui/avatar' import { Badge } from '@/components/ui/badge' import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs' import { useUser } from '@/context/user-context' -import { useChat } from '@/context/chat-context' -import { formatDistanceToNow } from 'date-fns' +import { useChat, useOnlineUsers } from '@/context/chat-context' interface ChatSidebarProps { isMobileMenuOpen: boolean @@ -41,6 +41,7 @@ export default function ChatSidebar({ }: ChatSidebarProps) { const { user: currentUser, updateUsername, logout } = useUser() const { chats, selectedChat, selectChat, createDirect, createGroup, joinGroup } = useChat() + const onlineUserIds = useOnlineUsers() const [searchQuery, setSearchQuery] = useState('') const [newGroupName, setNewGroupName] = useState('') @@ -50,6 +51,8 @@ export default function ChatSidebar({ const [chatTypeFilter, setChatTypeFilter] = useState('all') const [openContactsDialog, setOpenContactsDialog] = useState(false) + console.log('Online users:', onlineUserIds) + const fetchUsers = async (): Promise => { const response = await fetch(`${import.meta.env.VITE_API_URL}/api/users`, { headers: { diff --git a/client/src/context/chat-context.tsx b/client/src/context/chat-context.tsx index 0580a57..8214854 100644 --- a/client/src/context/chat-context.tsx +++ b/client/src/context/chat-context.tsx @@ -32,6 +32,7 @@ interface ChatContextType { } const ChatContext = createContext(undefined) +const OnlineUserContext = createContext([]) export const ChatProvider: React.FC<{ children: React.ReactNode }> = ({ children, @@ -54,7 +55,7 @@ export const ChatProvider: React.FC<{ children: React.ReactNode }> = ({ addOrUpdateMessageAtLast, removeMessage, } = useChatMessagesHelper(messages, setMessages) - const [onlineUsers, setOnlineUsers] = useState>(new Set()) + const [onlineUsers, setOnlineUsers] = useState([]) const socketRef = useRef(null) const [loadingChats, setLoadingChats] = useState(false) const [loadingMessages, setLoadingMessages] = useState(false) @@ -193,25 +194,24 @@ export const ChatProvider: React.FC<{ children: React.ReactNode }> = ({ if (!socketRef.current) return socketRef.current.on('socket-room-online-status', (res: any) => { - // console.log('Socket Room Online Status:', res) if (res.status === 'ok') { - const fetchOnlineUsers = async () => { - try { - const response = await fetch( - `${import.meta.env.VITE_API_URL}/api/users/online`, - ) - if (!response.ok) { - throw new Error('Failed to fetch online users') + const fetchOnlineUsers = async () => { + try { + const response = await fetch( + `${import.meta.env.VITE_API_URL}/api/users/online`, + ) + if (!response.ok) { + throw new Error('Failed to fetch online users') + } + const data = await response.json() + setOnlineUsers(data) + } catch (error) { + console.error('Error fetching online users:', error) } - const data = await response.json() - console.log('Online users:', data) - // setOnlineUsers(new Set(data.map((user: any) => user.id))) - }catch (error) { - console.error('Error fetching online users:', error) } + fetchOnlineUsers() } - fetchOnlineUsers() - }}) + }) socketRef.current.on('socket-room-message', (res) => { console.log('Message received:', res) @@ -422,7 +422,7 @@ export const ChatProvider: React.FC<{ children: React.ReactNode }> = ({ updateChat({ id: chatId, lastMessage: message.text, - lastSentAt: message.sentAt + lastSentAt: message.sentAt, }) } @@ -762,10 +762,18 @@ export const ChatProvider: React.FC<{ children: React.ReactNode }> = ({ }} > {children} + + {children} + ) } +export const useOnlineUsers = () => { + const context = useContext(OnlineUserContext) + return context +} + export const useChat = () => { const context = useContext(ChatContext) if (context === undefined) { From 70eb9cc7ea97ac694e753e08efe765a63929014f Mon Sep 17 00:00:00 2001 From: Jeerasak Kajonrattanavanit Date: Sun, 20 Apr 2025 01:19:26 +0700 Subject: [PATCH 3/5] feat:contact online status --- client/src/components/chat/chat-sidebar.tsx | 74 +++++++++++++-------- client/src/context/chat-context.tsx | 1 - 2 files changed, 48 insertions(+), 27 deletions(-) diff --git a/client/src/components/chat/chat-sidebar.tsx b/client/src/components/chat/chat-sidebar.tsx index 935a252..d23bd24 100644 --- a/client/src/components/chat/chat-sidebar.tsx +++ b/client/src/components/chat/chat-sidebar.tsx @@ -40,7 +40,14 @@ export default function ChatSidebar({ setIsMobileMenuOpen, }: ChatSidebarProps) { const { user: currentUser, updateUsername, logout } = useUser() - const { chats, selectedChat, selectChat, createDirect, createGroup, joinGroup } = useChat() + const { + chats, + selectedChat, + selectChat, + createDirect, + createGroup, + joinGroup, + } = useChat() const onlineUserIds = useOnlineUsers() const [searchQuery, setSearchQuery] = useState('') @@ -111,11 +118,13 @@ export default function ChatSidebar({ const filteredChats = chats.filter((chat) => { const resolvedName = chat.isGroup - ? chat.name ?? '' - : chat.participants?.find(p => currentUser && p.id !== currentUser.id)?.username ?? ''; + ? (chat.name ?? '') + : (chat.participants?.find((p) => currentUser && p.id !== currentUser.id) + ?.username ?? '') - - const matchesSearch = resolvedName.toLowerCase().includes(searchQuery.toLowerCase()) + const matchesSearch = resolvedName + .toLowerCase() + .includes(searchQuery.toLowerCase()) let matchesType = true if (chatTypeFilter !== 'all') { matchesType = chatTypeFilter === 'group' ? chat.isGroup : !chat.isGroup @@ -123,6 +132,8 @@ export default function ChatSidebar({ return matchesSearch && matchesType }) + // console.log('Filtered chats:', filteredChats) + return ( <> {/* Mobile menu button */} @@ -135,8 +146,9 @@ export default function ChatSidebar({ {/* Sidebar */}
{/* User profile */} @@ -230,9 +242,7 @@ export default function ChatSidebar({
{users ?.filter( - (user) => - currentUser && - user.id !== currentUser.id, + (user) => currentUser && user.id !== currentUser.id, ) .map((user) => (
+ {/* Updated online status indicator */} + {onlineUserIds.some( + (onlineUser) => onlineUser.id === user.id, + ) && ( +
+ )}
))}
@@ -294,17 +310,16 @@ export default function ChatSidebar({
{users ?.filter( - (user) => - currentUser && - user.id !== currentUser.id, + (user) => currentUser && user.id !== currentUser.id, ) .map((user) => (
u.id === user.id) - ? 'bg-gray-100' - : '' - }`} + className={`flex cursor-pointer items-center justify-between rounded-md p-2 ${ + selectedUsers.some((u) => u.id === user.id) + ? 'bg-gray-100' + : '' + }`} onClick={() => toggleUserSelection(user)} >
@@ -333,7 +348,9 @@ export default function ChatSidebar({ @@ -363,9 +380,11 @@ export default function ChatSidebar({ onChange={(e) => setGroupIdToJoin(e.target.value)} />
- + disabled={!groupIdToJoin.trim()} + > Join Group
@@ -389,10 +408,11 @@ export default function ChatSidebar({ filteredChats.map((chat) => (
{ selectChat(chat) setIsMobileMenuOpen(false) @@ -421,7 +441,9 @@ export default function ChatSidebar({
- {chat.lastSentAt ? formatDistanceToNow(chat.lastSentAt) : ''} + {chat.lastSentAt + ? formatDistanceToNow(chat.lastSentAt) + : ''} {chat.unread > 0 && ( @@ -442,4 +464,4 @@ export default function ChatSidebar({
) -} \ No newline at end of file +} diff --git a/client/src/context/chat-context.tsx b/client/src/context/chat-context.tsx index 8214854..c3a1fdb 100644 --- a/client/src/context/chat-context.tsx +++ b/client/src/context/chat-context.tsx @@ -761,7 +761,6 @@ export const ChatProvider: React.FC<{ children: React.ReactNode }> = ({ fetchMessageToChat, }} > - {children} {children} From 19ca042c5b0f4e03c4e22d9c55f66f94181947ee Mon Sep 17 00:00:00 2001 From: Jeerasak Kajonrattanavanit Date: Sun, 20 Apr 2025 01:47:43 +0700 Subject: [PATCH 4/5] feat:sidebar online status --- client/src/components/chat/chat-sidebar.tsx | 168 ++++++++++++-------- 1 file changed, 105 insertions(+), 63 deletions(-) diff --git a/client/src/components/chat/chat-sidebar.tsx b/client/src/components/chat/chat-sidebar.tsx index d23bd24..ffb88f8 100644 --- a/client/src/components/chat/chat-sidebar.tsx +++ b/client/src/components/chat/chat-sidebar.tsx @@ -58,8 +58,6 @@ export default function ChatSidebar({ const [chatTypeFilter, setChatTypeFilter] = useState('all') const [openContactsDialog, setOpenContactsDialog] = useState(false) - console.log('Online users:', onlineUserIds) - const fetchUsers = async (): Promise => { const response = await fetch(`${import.meta.env.VITE_API_URL}/api/users`, { headers: { @@ -132,7 +130,8 @@ export default function ChatSidebar({ return matchesSearch && matchesType }) - // console.log('Filtered chats:', filteredChats) + // console.log('Online users:', onlineUserIds) + console.log('Filtered chats:', filteredChats) return ( <> @@ -255,11 +254,18 @@ export default function ChatSidebar({ }} >
- - - {user.username.charAt(0)} - - +
+ + + {user.username.charAt(0)} + + + {onlineUserIds.some( + (onlineUser) => onlineUser.id === user.id, + ) && ( +
+ )} +

{user.username} @@ -269,12 +275,6 @@ export default function ChatSidebar({

- {/* Updated online status indicator */} - {onlineUserIds.some( - (onlineUser) => onlineUser.id === user.id, - ) && ( -
- )}
))}
@@ -323,11 +323,19 @@ export default function ChatSidebar({ onClick={() => toggleUserSelection(user)} >
- - - {user.username.charAt(0)} - - +
+ + + {user.username.charAt(0)} + + + {/* Green ring for online status */} + {onlineUserIds.some( + (onlineUser) => onlineUser.id === user.id, + ) && ( +
+ )} +

{user.username} @@ -405,54 +413,88 @@ export default function ChatSidebar({

{filteredChats.length > 0 ? ( - filteredChats.map((chat) => ( -
{ - selectChat(chat) - setIsMobileMenuOpen(false) - }} - > -
- - - {chat.name ? chat.name.charAt(0) : '?'} - - + filteredChats.map((chat) => { + const otherParticipant = !chat.isGroup + ? chat.participants?.find( + (p) => currentUser && p.id !== currentUser.id, + ) + : null -
-
-

{chat.name}

- {chat.isGroup && ( - - Group - - )} + const isDirectOnline = otherParticipant + ? onlineUserIds.some((u) => u.id === otherParticipant.id) + : false + + const isGroupOnline = chat.isGroup + ? chat.participants?.some( + (p) => + p.id !== currentUser?.id && + onlineUserIds.some( + (onlineUser) => onlineUser.id === p.id, + ), + ) + : false + + return ( +
{ + selectChat(chat) + setIsMobileMenuOpen(false) + }} + > +
+
+
+ + + {chat.name ? chat.name.charAt(0) : '?'} + + + {(isDirectOnline || isGroupOnline) && ( +
+ )} +
+
+
+
+

{chat.name}

+ {chat.isGroup && ( + + Group + + )} +
+

+ {chat.lastMessage} +

-

- {chat.lastMessage} -

+
+
+ + {chat.lastSentAt + ? formatDistanceToNow(chat.lastSentAt) + : ''} + + {chat.unread > 0 && ( + + {chat.unread} + + )}
-
- - {chat.lastSentAt - ? formatDistanceToNow(chat.lastSentAt) - : ''} - - {chat.unread > 0 && ( - - {chat.unread} - - )} -
-
- )) + ) + }) ) : (
No conversations found From 863497bee3058384ee6feb169d2ec984cba3b6c5 Mon Sep 17 00:00:00 2001 From: Jeerasak Kajonrattanavanit Date: Sun, 20 Apr 2025 02:38:01 +0700 Subject: [PATCH 5/5] feat:online status --- client/src/components/chat/chat-area.tsx | 138 +++++++++++++------- client/src/components/chat/chat-sidebar.tsx | 18 +-- 2 files changed, 100 insertions(+), 56 deletions(-) diff --git a/client/src/components/chat/chat-area.tsx b/client/src/components/chat/chat-area.tsx index 7b7f164..c95b3af 100644 --- a/client/src/components/chat/chat-area.tsx +++ b/client/src/components/chat/chat-area.tsx @@ -1,11 +1,20 @@ import React, { useEffect, useRef, useState } from 'react' -import { Edit, LogOut, Menu, MoreVertical, Send, SmilePlus, Trash2 } from 'lucide-react' +import { + Edit, + LogOut, + Menu, + MoreVertical, + Send, + SmilePlus, + Trash2, +} from 'lucide-react' +import { formatDistanceToNow } from 'date-fns' +import { emojiCategories, flatEmojiList } from './chat-emoji' import type { Message } from '@/lib/types' import { Avatar, AvatarFallback } from '@/components/ui/avatar' import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' import { DotPattern } from '@/components/ui/dot-pattern' -import { formatDistanceToNow } from 'date-fns' import { DropdownMenu, DropdownMenuContent, @@ -16,7 +25,7 @@ import { ScrollArea } from '@/components/ui/scroll-area' import { Textarea } from '@/components/ui/textarea' import { Input } from '@/components/ui/input' import { cn } from '@/lib/utils' -import { useChat } from '@/context/chat-context' +import { useChat, useOnlineUsers } from '@/context/chat-context' import { useUser } from '@/context/user-context' import { Dialog, @@ -26,7 +35,6 @@ import { DialogTitle, DialogTrigger, } from '@/components/ui/dialog' -import { flatEmojiList, emojiCategories } from './chat-emoji' interface ChatAreaProps { setIsMobileMenuOpen: (open: boolean) => void @@ -34,6 +42,7 @@ interface ChatAreaProps { export default function ChatArea({ setIsMobileMenuOpen }: ChatAreaProps) { const { user } = useUser() + const onlineUserIds = useOnlineUsers() const { selectedChat, messages, @@ -57,15 +66,17 @@ export default function ChatArea({ setIsMobileMenuOpen }: ChatAreaProps) { const currentChatMessages = selectedChat ? messages[selectedChat.id] : [] - type EmojiCategory = keyof typeof emojiCategories; + type EmojiCategory = keyof typeof emojiCategories - const [selectedCategory, setSelectedCategory] = useState('all'); + const [selectedCategory, setSelectedCategory] = useState< + EmojiCategory | 'all' + >('all') const getFilteredEmojis = () => { if (selectedCategory === 'all') { - return flatEmojiList; + return flatEmojiList } - return emojiCategories[selectedCategory] || []; - }; + return emojiCategories[selectedCategory] || [] + } useEffect(() => { if (chatAreaScrollDown) { @@ -190,6 +201,8 @@ export default function ChatArea({ setIsMobileMenuOpen }: ChatAreaProps) { (p) => user && p.id === user.id, ) + console.log(selectedChat) + return (
{/* Mobile menu toggle */} @@ -204,13 +217,20 @@ export default function ChatArea({ setIsMobileMenuOpen }: ChatAreaProps) {
{/* Avatar */} - - {/* Uncomment if you have avatar URLs */} - {/* */} - - {selectedChat.name ? selectedChat.name.charAt(0) : '?'} - - +
+ + + {selectedChat.name ? selectedChat.name.charAt(0) : '?'} + + {selectedChat.participants.some( + (p) => + p.id !== user?.id && + onlineUserIds.some((onlineUser) => onlineUser.id === p.id), + ) && ( +
+ )} + +
{/* Chat Info */}
@@ -264,13 +284,21 @@ export default function ChatArea({ setIsMobileMenuOpen }: ChatAreaProps) { }} >
- - - {participant.username - ? participant.username.charAt(0) - : '?'} - - +
+ + + {participant.username + ? participant.username.charAt(0) + : '?'} + + + {onlineUserIds.some( + (onlineUser) => + onlineUser.id === participant.id, + ) && ( +
+ )} +
{participant.username.length > 10 ? `${participant.username.slice(0, 10)}...` : participant.username} @@ -291,9 +319,9 @@ export default function ChatArea({ setIsMobileMenuOpen }: ChatAreaProps) { className={cn( 'ml-2 text-xs', participant.role === 'admin' && - 'bg-red-100 text-red-700', + 'bg-red-100 text-red-700', participant.role === 'member' && - 'bg-blue-100 text-blue-700', + 'bg-blue-100 text-blue-700', )} > {participant.role} @@ -372,14 +400,26 @@ export default function ChatArea({ setIsMobileMenuOpen }: ChatAreaProps) { > {message.senderType === 'user' ? (
{!isCurrentUser && selectedChat.isGroup && (
- {sender?.username || 'Unknown user'} +
+
onlineUser.id === sender?.id, + ) + ? 'bg-green-500' + : 'bg-gray-300' + }`} + /> + {sender?.username || 'Unknown user'} +
)} @@ -523,18 +563,15 @@ export default function ChatArea({ setIsMobileMenuOpen }: ChatAreaProps) { - +
@@ -542,24 +579,29 @@ export default function ChatArea({ setIsMobileMenuOpen }: ChatAreaProps) { {Object.keys(emojiCategories).map((category) => ( ))}
-
+
{getFilteredEmojis().map((emoji, i) => ( - setMessageText(messageText + emoji)}> + setMessageText(messageText + emoji)} + > {emoji} ))}
-
-
+
) } diff --git a/client/src/components/chat/chat-sidebar.tsx b/client/src/components/chat/chat-sidebar.tsx index ffb88f8..762a5d3 100644 --- a/client/src/components/chat/chat-sidebar.tsx +++ b/client/src/components/chat/chat-sidebar.tsx @@ -130,9 +130,6 @@ export default function ChatSidebar({ return matchesSearch && matchesType }) - // console.log('Online users:', onlineUserIds) - console.log('Filtered chats:', filteredChats) - return ( <> {/* Mobile menu button */} @@ -153,11 +150,16 @@ export default function ChatSidebar({ {/* User profile */}
- - - {currentUser?.username ? currentUser.username.charAt(0) : '?'} - - +
+ + + {currentUser?.username + ? currentUser.username.charAt(0) + : '?'} + + +
+

{currentUser?.username}

{currentUser?.email}