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 49f5f7c..762a5d3 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 @@ -40,7 +40,15 @@ 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('') const [newGroupName, setNewGroupName] = useState('') @@ -108,11 +116,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 @@ -132,18 +142,24 @@ export default function ChatSidebar({ {/* Sidebar */}
{/* User profile */}
- - - {currentUser?.username ? currentUser.username.charAt(0) : '?'} - - +
+ + + {currentUser?.username + ? currentUser.username.charAt(0) + : '?'} + + +
+

{currentUser?.username}

{currentUser?.email}

@@ -227,9 +243,7 @@ export default function ChatSidebar({
{users ?.filter( - (user) => - currentUser && - user.id !== currentUser.id, + (user) => currentUser && user.id !== currentUser.id, ) .map((user) => (
- - - {user.username.charAt(0)} - - +
+ + + {user.username.charAt(0)} + + + {onlineUserIds.some( + (onlineUser) => onlineUser.id === user.id, + ) && ( +
+ )} +

{user.username} @@ -291,25 +312,32 @@ 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)} >
- - - {user.username.charAt(0)} - - +
+ + + {user.username.charAt(0)} + + + {/* Green ring for online status */} + {onlineUserIds.some( + (onlineUser) => onlineUser.id === user.id, + ) && ( +
+ )} +

{user.username} @@ -330,7 +358,9 @@ export default function ChatSidebar({ @@ -360,9 +390,11 @@ export default function ChatSidebar({ onChange={(e) => setGroupIdToJoin(e.target.value)} />

- + disabled={!groupIdToJoin.trim()} + > Join Group
@@ -383,51 +415,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 + + const isDirectOnline = otherParticipant + ? onlineUserIds.some((u) => u.id === otherParticipant.id) + : false -
-
-

{chat.name}

- {chat.isGroup && ( - - Group - - )} + 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 @@ -439,4 +508,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 e7dc2f3..c3a1fdb 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,6 +55,7 @@ export const ChatProvider: React.FC<{ children: React.ReactNode }> = ({ addOrUpdateMessageAtLast, removeMessage, } = useChatMessagesHelper(messages, setMessages) + const [onlineUsers, setOnlineUsers] = useState([]) const socketRef = useRef(null) const [loadingChats, setLoadingChats] = useState(false) const [loadingMessages, setLoadingMessages] = useState(false) @@ -191,6 +193,26 @@ 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) => { + 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() + setOnlineUsers(data) + } 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') { @@ -400,7 +422,7 @@ export const ChatProvider: React.FC<{ children: React.ReactNode }> = ({ updateChat({ id: chatId, lastMessage: message.text, - lastSentAt: message.sentAt + lastSentAt: message.sentAt, }) } @@ -739,11 +761,18 @@ export const ChatProvider: React.FC<{ children: React.ReactNode }> = ({ fetchMessageToChat, }} > - {children} + + {children} + ) } +export const useOnlineUsers = () => { + const context = useContext(OnlineUserContext) + return context +} + export const useChat = () => { const context = useContext(ChatContext) if (context === undefined) { 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;